claude-dev-env 2.31.1 → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/clean-coder.md +8 -7
- package/.agents/agents/code-quality-agent.md +4 -2
- package/.agents/agents/pr-description-writer.md +8 -3
- package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
- package/.agents/agents/test_agent_frontmatter.py +0 -9
- package/.agents/agents/test_pr_description_writer_style.py +24 -1
- package/.agents/skills/e-simplify/SKILL.md +1 -15
- package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
- package/.agents/skills/pr-refinement/SKILL.md +2 -2
- package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
- package/.agents/skills/pr-title-description/SKILL.md +6 -2
- package/.agents/skills/review-tier/SKILL.md +1 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
- package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
- package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
- package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
- package/AGENTS.md +83 -0
- package/_shared/pr-loop/code-rules-gate.md +5 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
- package/audit-rubrics/audit-categories.json +2 -2
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
- package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +8 -9
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_comments.py +118 -59
- package/hooks/blocking/code_rules_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer.py +19 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_type_escape.py +16 -23
- package/hooks/blocking/javascript_comment_scanner.py +99 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
- package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -1
- package/hooks/git-hooks/pull_request_handoff.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +8 -0
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
- package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/comment_checks.py +2 -2
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/fast_save_validators.py +0 -9
- package/hooks/validators/git_checks.py +0 -84
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +89 -42
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_fast_save_validators.py +2 -8
- package/hooks/validators/test_git_checks.py +0 -249
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_todo_checks.py +1 -0
- package/hooks/validators/todo_checks.py +1 -1
- package/package.json +1 -1
- package/rules/git-workflow.md +0 -8
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/check.ps1 +35 -1
- package/scripts/codex_compat_materializer.py +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
- package/scripts/tests/test_rules.py +111 -0
- package/system-prompts/software-engineer.xml +6 -19
- package/.agents/skills-archived/split-pr/SKILL.md +0 -85
- package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
- package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
- package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
- package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
- package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
- package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
- package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
- package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
- package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
- package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
- package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
- package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
- package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
- package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
- package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
- package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
- package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
- package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
- package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
- package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
- package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
- package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
- package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
- package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
- package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
|
@@ -28,5 +28,5 @@ def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def build_pull_request_reminder(url: str) -> str:
|
|
31
|
-
prompt = "Review this pull request: <PR link>\n\nRead the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.\n\
|
|
31
|
+
prompt = "Review this pull request: <PR link>\n\nRead the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.\n\nBefore writing, inventory every independently observable behavior in the full diff. A behavior is independent when a user, caller, operator, automation, output, error, exit status, fallback, or side effect can observe it separately. Lead with the central behavior for readability, then give every other behavior its own short paragraph or bullet.\n\nFor each behavior, state the trigger or observer, the Before -> After result, the affected surface/caller when shared, and focused proof. Include important preserved behavior and fallback paths when they help a coder rule a regression in or out. Describe outcomes, not line-by-line implementation, and do not hide multiple behaviors under vague umbrella wording.\n\nWrite like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.\n\nYour job is not a dry API blurb. Your job is an illustrative before/after a person can picture.\n\nGold voice for titles and “What this adds”:\n\nThe theme package has a phone icon somewhere. We need to find it.\n\nBefore: yell “anyone know how to find icons?” and wait for a hand.\n\nAfter: call the finder by its name tag: “icon finder, go.”\n\nThat’s all “operation id” is — the finder’s name tag.\n\nRules for that voice:\n\nStart from a concrete scene (what’s in someone’s hand, what they’re hunting for).\nPrefer Before / After when the change is “how you ask” or “how you look it up.”\nUse picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.\nPrefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”\nWhen the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:\nDrop it and say what the person does with their hands, or\nKeep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.\nNever leave jargon unexplained. Never write as if the reader already knows the jargon.\nAdd a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).\nFew words. Small words. Clear on first read.\nReturn:\n\nRecommended title\n\nFew words. Small words. Clear on first read.\nSay what you hand it, what it does, and what you get back — or the Before/After of how you ask.\nPrefer picture words and spoken results.\nGood title: “Call the icon finder by its name tag instead of asking around”\nGood title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”\nBad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.\nUse “Add” only if it helps. Do not force it.\nNo vague words like “improve,” “enhance,” or “update.”\nReturn one title only.\nWhy this title fits\n\n1–2 short sentences in the same voice.\nIf needed, one Before / After beat, then what you get back.\nIf this section is needed to decode the title, rewrite the title.\nEvidence\n\nKey files or functions that support the title.\nIf you cannot read the PR or full diff, say so and do not propose a title.\n\nThen write a short, paste-ready PR description.\n\nUse this structure:\n\nWhat this adds\n1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature.\n\nWhy\nOne short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”\n\nVerification\nList only proof from the PR, CI, or your own checks.\n\nReported in the PR\n\n…\nChecked by you / CI here\n\n…\nRules:\n\nFew words. Small words. Clear on first read. ELI5 I-am-stupid mark.\nDo not invent behavior, tests, or benefits.\nStrip branch names, hashes, draft notes, agent notes, and merge notes.\nNo vague words like “improve,” “enhance,” or “update.”\nIllustrative first. Abstract labels only if glossed in the same breath.\nUse the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result."
|
|
32
32
|
return prompt.replace("<PR link>", url)
|
|
@@ -242,6 +242,14 @@ def test_pull_request_reminder_prints_url(
|
|
|
242
242
|
assert output.startswith("Review this pull request: https://github.com/example/repo/pull/1\n")
|
|
243
243
|
assert "<PR link>" not in output
|
|
244
244
|
assert "Do not use the branch name, commit message, labels, current title" in output
|
|
245
|
+
assert (
|
|
246
|
+
"Before writing, inventory every independently observable behavior in the full diff."
|
|
247
|
+
in output
|
|
248
|
+
and "Lead with the central behavior for readability, then give every other behavior its own short paragraph or bullet."
|
|
249
|
+
in output
|
|
250
|
+
and "For each behavior, state the trigger or observer, the Before -> After result" in output
|
|
251
|
+
and "OVERRIDE:" not in output
|
|
252
|
+
)
|
|
245
253
|
assert "gh pr edit https://github.com/example/repo/pull/1" in output
|
|
246
254
|
assert output.rstrip().endswith("Report the saved result.")
|
|
247
255
|
|
package/hooks/hooks.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
6
|
-
"matcher": "Write|Edit",
|
|
6
|
+
"matcher": "Write|Edit|MultiEdit|apply_patch",
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
"hooks": [
|
|
265
265
|
{
|
|
266
266
|
"type": "command",
|
|
267
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/
|
|
267
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/bash_post_call_dispatcher.py",
|
|
268
268
|
"timeout": 60
|
|
269
269
|
}
|
|
270
270
|
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Constants for the Bash PostToolUse dispatcher.
|
|
2
|
+
|
|
3
|
+
Holds the ordered hosted-hook roster this dispatcher runs after a Bash call
|
|
4
|
+
finishes. Reuses ``BashHostedHookEntry`` and the Bash-only tool-name set from
|
|
5
|
+
``bash_pre_tool_use_dispatcher_constants`` -- the entry shape and the tool-name
|
|
6
|
+
membership question are identical on the PostToolUse side, so this module adds
|
|
7
|
+
no second copy of either.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
13
|
+
ALL_BASH_ONLY_TOOL_NAMES,
|
|
14
|
+
BashHostedHookEntry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"]
|
|
18
|
+
|
|
19
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
20
|
+
BashHostedHookEntry("blocking/gh_pr_author_restore.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
21
|
+
BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
22
|
+
)
|
|
@@ -13,16 +13,27 @@ blocking the tool call.
|
|
|
13
13
|
run = run_hook_capturing_output("/hooks/blocking/gate.py", '{"tool_name": "Bash"}')
|
|
14
14
|
ok: run.captured_stdout carries the gate's deny JSON, run.did_crash is False
|
|
15
15
|
flag: run.did_crash is True when the gate raised, run.captured_stdout is ""
|
|
16
|
+
|
|
17
|
+
``run_dispatcher_main`` holds the entry-point shell a hosted-hook dispatcher's
|
|
18
|
+
own ``main`` needs: read one stdin payload, resolve its tool_name, and hand
|
|
19
|
+
both to the dispatcher's own ``dispatch`` callback. Every dispatcher's
|
|
20
|
+
``main`` reduces to one call into this shared shell, so the read-resolve-exit
|
|
21
|
+
boilerplate lives in one place rather than once per dispatcher.
|
|
16
22
|
"""
|
|
17
23
|
|
|
18
24
|
from __future__ import annotations
|
|
19
25
|
|
|
20
26
|
import io
|
|
27
|
+
import json
|
|
21
28
|
import runpy
|
|
22
29
|
import sys
|
|
30
|
+
from pathlib import Path
|
|
23
31
|
import traceback
|
|
32
|
+
from collections.abc import Callable
|
|
24
33
|
from dataclasses import dataclass
|
|
25
34
|
|
|
35
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
36
|
+
|
|
26
37
|
|
|
27
38
|
@dataclass
|
|
28
39
|
class HostedHookRun:
|
|
@@ -71,3 +82,45 @@ def run_hook_capturing_output(hook_script_path: str, payload_text: str) -> Hoste
|
|
|
71
82
|
finally:
|
|
72
83
|
sys.stdin, sys.stdout, sys.argv = original_stdin, original_stdout, original_argv
|
|
73
84
|
return HostedHookRun(captured_stdout=captured_output.getvalue(), did_crash=did_crash)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolved_hook_script_path(relative_path: str) -> str:
|
|
88
|
+
"""Resolve a hooks/-relative path to the absolute script path to run.
|
|
89
|
+
|
|
90
|
+
::
|
|
91
|
+
|
|
92
|
+
"observability/test_failure_recorder.py" -> <hooks>/observability/...
|
|
93
|
+
|
|
94
|
+
The root comes from this module's own location, so every dispatcher
|
|
95
|
+
resolves against the same directory however deep it sits.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
relative_path: A path relative to the hooks directory.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The absolute path of that script.
|
|
102
|
+
"""
|
|
103
|
+
hooks_root = Path(__file__).resolve().parent.parent
|
|
104
|
+
return str(hooks_root / relative_path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_dispatcher_main(dispatch: Callable[[str, str], None]) -> None:
|
|
108
|
+
"""Read one stdin payload and hand its JSON text and tool_name to dispatch.
|
|
109
|
+
|
|
110
|
+
Every hosted-hook dispatcher's own ``main`` reduces to this one call.
|
|
111
|
+
Exits 0 before ever calling dispatch when stdin is empty, malformed, or
|
|
112
|
+
names no tool_name string; always exits 0 afterward, since a dispatcher
|
|
113
|
+
signals its own outcome, if any, through what dispatch itself writes.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
dispatch: The dispatcher's own ``dispatch(payload_text, tool_name)``.
|
|
117
|
+
"""
|
|
118
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
119
|
+
if payload_dictionary is None:
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
payload_text = json.dumps(payload_dictionary)
|
|
122
|
+
tool_name = payload_dictionary.get("tool_name", "")
|
|
123
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
dispatch(payload_text, tool_name)
|
|
126
|
+
sys.exit(0)
|
|
@@ -91,7 +91,9 @@ bare, quoted, or backslash-escaped (``<<\\EOF`` quotes the delimiter the way
|
|
|
91
91
|
``<<'EOF'`` does). ``<<<`` is a here-string rather than a heredoc, so the
|
|
92
92
|
pattern refuses to read one as an opener and leave the lines below it unread.
|
|
93
93
|
The opener also reports its own ``-``, because ``<<`` closes on a line spelling
|
|
94
|
-
the delimiter exactly while ``<<-`` closes on one carrying leading tabs.
|
|
94
|
+
the delimiter exactly while ``<<-`` closes on one carrying leading tabs. It
|
|
95
|
+
reports the quote and the backslash separately from the delimiter word, because
|
|
96
|
+
either one tells a caller that bash expands nothing inside the body below.
|
|
95
97
|
|
|
96
98
|
The string-executing shell basenames and command flags start from the shared
|
|
97
99
|
``unscoped_search_blocker_constants.py`` sets and add the Windows command shell
|
|
@@ -167,8 +169,10 @@ __all__ = [
|
|
|
167
169
|
"HEREDOC_OPENER_PATTERN",
|
|
168
170
|
"HEREDOC_TAB_STRIP_GROUP",
|
|
169
171
|
"HEREDOC_QUOTE_GROUP",
|
|
172
|
+
"HEREDOC_ESCAPE_GROUP",
|
|
170
173
|
"HEREDOC_TERMINATOR_GROUP",
|
|
171
174
|
"HEREDOC_TAB_STRIP_MARKER",
|
|
175
|
+
"HEREDOC_UNQUOTED_MARKER",
|
|
172
176
|
"HEREDOC_STRIPPED_INDENT_CHARACTERS",
|
|
173
177
|
"NO_FOLLOWING_OPERATOR",
|
|
174
178
|
"ALL_QUOTE_CHARACTERS",
|
|
@@ -340,13 +344,16 @@ CLOSED_GROUP_DEPTH = 0
|
|
|
340
344
|
_HEREDOC_DELIMITER_CHARACTER_CLASS = r"[^\s'\"<>|&;()`$\\]"
|
|
341
345
|
HEREDOC_TAB_STRIP_GROUP = "heredoc_tab_strip"
|
|
342
346
|
HEREDOC_QUOTE_GROUP = "heredoc_quote"
|
|
347
|
+
HEREDOC_ESCAPE_GROUP = "heredoc_escape"
|
|
343
348
|
HEREDOC_TERMINATOR_GROUP = "heredoc_terminator"
|
|
344
349
|
HEREDOC_OPENER_PATTERN = re.compile(
|
|
345
|
-
rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)
|
|
350
|
+
rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)"
|
|
351
|
+
rf"(?P<{HEREDOC_ESCAPE_GROUP}>\\?)"
|
|
346
352
|
rf"(?P<{HEREDOC_TERMINATOR_GROUP}>{_HEREDOC_DELIMITER_CHARACTER_CLASS}+)"
|
|
347
353
|
rf"(?P={HEREDOC_QUOTE_GROUP})"
|
|
348
354
|
)
|
|
349
355
|
HEREDOC_TAB_STRIP_MARKER = "-"
|
|
356
|
+
HEREDOC_UNQUOTED_MARKER = ""
|
|
350
357
|
HEREDOC_STRIPPED_INDENT_CHARACTERS = "\t"
|
|
351
358
|
NO_FOLLOWING_OPERATOR = ""
|
|
352
359
|
ALL_QUOTE_CHARACTERS = "\"'"
|
|
@@ -25,6 +25,11 @@ __all__ = [
|
|
|
25
25
|
"PROCESS_SUBSTITUTION_PATTERN",
|
|
26
26
|
"SINGLE_QUOTED_RUN_PATTERN",
|
|
27
27
|
"STRIPPED_RUN_REPLACEMENT",
|
|
28
|
+
"COMMAND_LINE_SEPARATOR",
|
|
29
|
+
"BACKSLASH_CHARACTER",
|
|
30
|
+
"SINGLE_QUOTE_CHARACTER",
|
|
31
|
+
"DOUBLE_QUOTE_CHARACTER",
|
|
32
|
+
"NO_TERMINATOR_INDEX",
|
|
28
33
|
"CORRECTIVE_MESSAGE",
|
|
29
34
|
]
|
|
30
35
|
|
|
@@ -49,6 +54,11 @@ EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
|
|
|
49
54
|
PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
|
|
50
55
|
SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
|
|
51
56
|
STRIPPED_RUN_REPLACEMENT = ""
|
|
57
|
+
COMMAND_LINE_SEPARATOR = "\n"
|
|
58
|
+
BACKSLASH_CHARACTER = "\\"
|
|
59
|
+
SINGLE_QUOTE_CHARACTER = "'"
|
|
60
|
+
DOUBLE_QUOTE_CHARACTER = '"'
|
|
61
|
+
NO_TERMINATOR_INDEX = -1
|
|
52
62
|
|
|
53
63
|
CORRECTIVE_MESSAGE = (
|
|
54
64
|
"BLOCKED [shell-substitution]: command contains $(...), backtick, or "
|
|
@@ -60,14 +60,13 @@ STALE_COMMENT_DENY_TEMPLATE: str = (
|
|
|
60
60
|
"The comment above the changed line in {file_path} names "
|
|
61
61
|
"'{orphaned_name}', which this edit removes from the line below. "
|
|
62
62
|
"The comment left in place reads: {contradicted_comment}. "
|
|
63
|
-
"
|
|
63
|
+
"Remove the comment in the same edit."
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
STALE_COMMENT_ADDITIONAL_CONTEXT: str = (
|
|
67
67
|
"A standalone comment directly above an edited line describes that "
|
|
68
|
-
"line. When the edit drops a name the comment carries,
|
|
69
|
-
"comment
|
|
70
|
-
"the same Edit call."
|
|
68
|
+
"line. When the edit drops a name the comment carries, remove the "
|
|
69
|
+
"comment inside the same Edit call."
|
|
71
70
|
)
|
|
72
71
|
|
|
73
72
|
STALE_COMMENT_SYSTEM_MESSAGE: str = (
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Hold a best-effort exclusive lock across one read-modify-write of a state file.
|
|
2
|
+
|
|
3
|
+
Two hooks can reach the same state file in one turn. The file is written
|
|
4
|
+
through a temporary-plus-replace, so it is never torn; what this closes is
|
|
5
|
+
the separate lost-update race, where one writer reads before the other
|
|
6
|
+
writes and then overwrites it.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
with hold_state_file_lock(state_file):
|
|
11
|
+
all_stored = read(state_file)
|
|
12
|
+
write(state_file, updated(all_stored))
|
|
13
|
+
|
|
14
|
+
The lock is best effort. A caller that cannot take it inside the timeout
|
|
15
|
+
proceeds without it rather than stalling the tool call that triggered it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
28
|
+
if _hooks_directory not in sys.path:
|
|
29
|
+
sys.path.insert(0, _hooks_directory)
|
|
30
|
+
|
|
31
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
32
|
+
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
33
|
+
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
34
|
+
SESSION_EDIT_LOCK_FILE_SUFFIX,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _acquire_lock_descriptor(lock_file: Path) -> int | None:
|
|
39
|
+
"""Grab the exclusive lock, spinning until it frees or the timeout passes.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
lock_file: Path to the lock file beside the state file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
An open descriptor for the held lock, or None when it stayed held
|
|
46
|
+
past the acquire timeout.
|
|
47
|
+
"""
|
|
48
|
+
lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
|
|
49
|
+
while True:
|
|
50
|
+
try:
|
|
51
|
+
return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
52
|
+
except FileExistsError:
|
|
53
|
+
if time.monotonic() >= lock_acquire_deadline:
|
|
54
|
+
return None
|
|
55
|
+
time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextlib.contextmanager
|
|
59
|
+
def hold_state_file_lock(state_file: Path) -> Iterator[None]:
|
|
60
|
+
"""Hold the lock beside one state file for the body of the with-block.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
state_file: The state file whose read-modify-write is being guarded.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
Control to the caller while the lock is held.
|
|
67
|
+
"""
|
|
68
|
+
lock_file = state_file.with_name(state_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
|
|
69
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
lock_descriptor = _acquire_lock_descriptor(lock_file)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
if lock_descriptor is not None:
|
|
75
|
+
os.close(lock_descriptor)
|
|
76
|
+
lock_file.unlink(missing_ok=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Behavioral tests for the Bash PostToolUse dispatcher's hosted-hook roster."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
4
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
5
|
+
)
|
|
6
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_roster_names_the_gh_pr_author_restore_hook() -> None:
|
|
10
|
+
all_script_paths = [
|
|
11
|
+
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
12
|
+
]
|
|
13
|
+
assert "blocking/gh_pr_author_restore.py" in all_script_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_roster_names_the_test_failure_recorder_hook() -> None:
|
|
17
|
+
all_script_paths = [
|
|
18
|
+
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
19
|
+
]
|
|
20
|
+
assert "observability/test_failure_recorder.py" in all_script_paths
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_every_roster_entry_applies_to_bash() -> None:
|
|
24
|
+
assert all(
|
|
25
|
+
BASH_TOOL_NAME in each_entry.applicable_tool_names
|
|
26
|
+
for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
27
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
harness's own exit-code error prefix, the 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
|
+
``BASH_TOOL_NAME`` lives in ``gh_pr_author_swap_constants`` already; this
|
|
10
|
+
module reuses that name rather than a second literal. The pytest-program and
|
|
11
|
+
interpreter recognition here is deliberately its own small, stdlib-only
|
|
12
|
+
check rather than a reuse of ``piped_pytest_blocker_constants`` /
|
|
13
|
+
``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
|
|
14
|
+
pull in a much larger transitive import for every Bash call regardless of
|
|
15
|
+
content, working against the point of the recognizer's own cheap prefilter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
|
|
23
|
+
EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
|
|
24
|
+
OPTION_TOKEN_PREFIX: str = "-"
|
|
25
|
+
NODE_ID_FILE_PATH_SEPARATOR: str = "::"
|
|
26
|
+
ALL_CHAINING_OPERATOR_SUBSTRINGS: tuple[str, ...] = ("&&", "||", ";", "|", "\n")
|
|
27
|
+
ALL_REDIRECTION_TOKENS: frozenset[str] = frozenset(
|
|
28
|
+
{">", ">>", "<", "<<", "2>&1", "1>&2", "2>", "1>"}
|
|
29
|
+
)
|
|
30
|
+
ALL_PYTEST_PROGRAM_BASENAMES: frozenset[str] = frozenset({"pytest", "py.test"})
|
|
31
|
+
MODULE_RUN_FLAG: str = "-m"
|
|
32
|
+
PYTEST_MODULE_NAME: str = "pytest"
|
|
33
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN: re.Pattern[str] = re.compile(
|
|
34
|
+
r"^(?:python|py|pypy)[0-9.]*(?:\.exe)?$"
|
|
35
|
+
)
|
|
@@ -7,15 +7,22 @@ and the restoration of the interpreter's stdin, stdout, and argv.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import io
|
|
10
11
|
import json
|
|
11
12
|
import sys
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
14
17
|
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
15
18
|
if _HOOKS_DIR not in sys.path:
|
|
16
19
|
sys.path.insert(0, _HOOKS_DIR)
|
|
17
20
|
|
|
18
|
-
from hooks_constants.hosted_hook_runner import
|
|
21
|
+
from hooks_constants.hosted_hook_runner import ( # noqa: E402
|
|
22
|
+
HostedHookRun,
|
|
23
|
+
run_dispatcher_main,
|
|
24
|
+
run_hook_capturing_output,
|
|
25
|
+
)
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def _write_probe(tmp_path: Path, body: str) -> str:
|
|
@@ -99,3 +106,50 @@ def test_returns_a_hosted_hook_run_instance(tmp_path: Path) -> None:
|
|
|
99
106
|
probe_path = _write_probe(tmp_path, "print('x')\n")
|
|
100
107
|
hook_run = run_hook_capturing_output(probe_path, "{}")
|
|
101
108
|
assert isinstance(hook_run, HostedHookRun)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_run_dispatcher_main_calls_dispatch_with_payload_text_and_tool_name(
|
|
112
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""A well-formed payload reaches dispatch with its JSON text and tool_name."""
|
|
115
|
+
all_calls: list[tuple[str, str]] = []
|
|
116
|
+
monkeypatch.setattr(
|
|
117
|
+
sys, "stdin", io.StringIO(json.dumps({"tool_name": "Bash", "tool_input": {}}))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
121
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
122
|
+
|
|
123
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
124
|
+
assert len(all_calls) == 1
|
|
125
|
+
recorded_payload_text, recorded_tool_name = all_calls[0]
|
|
126
|
+
assert recorded_tool_name == "Bash"
|
|
127
|
+
assert json.loads(recorded_payload_text)["tool_name"] == "Bash"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_run_dispatcher_main_skips_dispatch_for_malformed_stdin(
|
|
131
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Unparseable stdin exits zero and never calls dispatch."""
|
|
134
|
+
all_calls: list[tuple[str, str]] = []
|
|
135
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
136
|
+
|
|
137
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
138
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
139
|
+
|
|
140
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
141
|
+
assert all_calls == []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_run_dispatcher_main_skips_dispatch_when_tool_name_is_missing(
|
|
145
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""A payload with no tool_name exits zero and never calls dispatch."""
|
|
148
|
+
all_calls: list[tuple[str, str]] = []
|
|
149
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_input": {}})))
|
|
150
|
+
|
|
151
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
152
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
153
|
+
|
|
154
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
155
|
+
assert all_calls == []
|
|
@@ -9,7 +9,15 @@ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
|
9
9
|
if str(_HOOKS_ROOT) not in sys.path:
|
|
10
10
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
11
|
|
|
12
|
-
from hooks_constants.unused_module_import_constants import
|
|
12
|
+
from hooks_constants.unused_module_import_constants import (
|
|
13
|
+
UNUSED_IMPORT_GUIDANCE,
|
|
14
|
+
line_suppresses_unused_import_via_noqa,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_unused_import_guidance_uses_comment_free_side_effect_remediation() -> None:
|
|
19
|
+
assert "noqa" not in UNUSED_IMPORT_GUIDANCE
|
|
20
|
+
assert "importlib.import_module" in UNUSED_IMPORT_GUIDANCE
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
def test_line_suppresses_bare_noqa() -> None:
|
|
@@ -11,7 +11,7 @@ NOQA_DIRECTIVE_PATTERN: re.Pattern[str] = re.compile(
|
|
|
11
11
|
)
|
|
12
12
|
MAX_UNUSED_IMPORT_ISSUES: int = 25
|
|
13
13
|
UNUSED_IMPORT_GUIDANCE: str = (
|
|
14
|
-
"remove unused import; if
|
|
14
|
+
"remove unused import; if side effects are required, call importlib.import_module explicitly"
|
|
15
15
|
)
|
|
16
16
|
TYPE_CHECKING_IDENTIFIER: str = "TYPE_CHECKING"
|
|
17
17
|
|
|
@@ -18,13 +18,10 @@ never interrupts the edit that triggered it.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import contextlib
|
|
22
21
|
import json
|
|
23
22
|
import os
|
|
24
23
|
import sys
|
|
25
24
|
import tempfile
|
|
26
|
-
import time
|
|
27
|
-
from collections.abc import Iterator
|
|
28
25
|
from pathlib import Path
|
|
29
26
|
|
|
30
27
|
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
@@ -35,15 +32,13 @@ from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
|
|
|
35
32
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
36
33
|
read_hook_input_dictionary_from_stdin,
|
|
37
34
|
)
|
|
35
|
+
from hooks_constants.state_file_lock import hold_state_file_lock # noqa: E402
|
|
38
36
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
39
37
|
ALL_EDITED_FILE_PATHS_KEY,
|
|
40
38
|
ALL_TRACKED_EDIT_TOOL_NAMES,
|
|
41
39
|
APPLY_PATCH_TOOL_NAME,
|
|
42
|
-
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
43
|
-
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
44
40
|
SESSION_EDIT_FILE_PREFIX,
|
|
45
41
|
SESSION_EDIT_FILE_SUFFIX,
|
|
46
|
-
SESSION_EDIT_LOCK_FILE_SUFFIX,
|
|
47
42
|
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
48
43
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
49
44
|
STATE_FILE_DEFAULT_SESSION_ID,
|
|
@@ -140,54 +135,6 @@ def _atomic_write_edit_file(edit_file: Path, all_edited_file_paths: list[str]) -
|
|
|
140
135
|
raise
|
|
141
136
|
|
|
142
137
|
|
|
143
|
-
def _acquire_edit_file_lock(lock_file: Path) -> int | None:
|
|
144
|
-
"""Grab an exclusive per-session lock, spinning until it frees or times out.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
lock_file: Path to this session's lock file.
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
An open file descriptor for the held lock, or None when the lock stayed
|
|
151
|
-
held past the acquire timeout so the caller proceeds without it rather
|
|
152
|
-
than stalling the edit that triggered the hook.
|
|
153
|
-
"""
|
|
154
|
-
lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
|
|
155
|
-
while True:
|
|
156
|
-
try:
|
|
157
|
-
return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
158
|
-
except FileExistsError:
|
|
159
|
-
if time.monotonic() >= lock_acquire_deadline:
|
|
160
|
-
return None
|
|
161
|
-
time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@contextlib.contextmanager
|
|
165
|
-
def _hold_edit_file_lock(edit_file: Path) -> Iterator[None]:
|
|
166
|
-
"""Hold the per-session lock across one read-modify-write of the tracker.
|
|
167
|
-
|
|
168
|
-
Two PostToolUse invocations run concurrently when Claude issues parallel
|
|
169
|
-
Write or Edit calls in one turn. Serializing the read-then-write on a shared
|
|
170
|
-
lock file keeps each invocation's appended path from overwriting another's.
|
|
171
|
-
The lock is best-effort: when it cannot be acquired within the timeout, the
|
|
172
|
-
write proceeds without it rather than stalling the edit.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
edit_file: This session's tracker file path.
|
|
176
|
-
|
|
177
|
-
Yields:
|
|
178
|
-
Control to the caller while the lock is held.
|
|
179
|
-
"""
|
|
180
|
-
lock_file = edit_file.with_name(edit_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
|
|
181
|
-
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
-
lock_descriptor = _acquire_edit_file_lock(lock_file)
|
|
183
|
-
try:
|
|
184
|
-
yield
|
|
185
|
-
finally:
|
|
186
|
-
if lock_descriptor is not None:
|
|
187
|
-
os.close(lock_descriptor)
|
|
188
|
-
lock_file.unlink(missing_ok=True)
|
|
189
|
-
|
|
190
|
-
|
|
191
138
|
def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
192
139
|
"""Append one edited path to this session's tracker file when it is new.
|
|
193
140
|
|
|
@@ -200,7 +147,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
|
200
147
|
resolved_file_path: The resolved absolute path of the edited file.
|
|
201
148
|
"""
|
|
202
149
|
edit_file = _session_edit_file_path(session_id)
|
|
203
|
-
with
|
|
150
|
+
with hold_state_file_lock(edit_file):
|
|
204
151
|
recorded_paths = _read_recorded_paths(edit_file)
|
|
205
152
|
if resolved_file_path in recorded_paths:
|
|
206
153
|
return
|