claude-dev-env 8.100.0 → 8.101.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.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook that runs a repository's lint on each file the agent edits.
3
+
4
+ The agent edits ``tools/report.py`` in a repository that opts in. The hook runs
5
+ that repository's lint command on the file and hands the findings back to the
6
+ agent, so it fixes them in the same turn::
7
+
8
+ environment list: owner/repo
9
+ <repo>/.claude/edit-lint.json:
10
+ {"file_suffixes": [".py"],
11
+ "command": ["python", "lint.py", "--advisory", "{file}"]}
12
+
13
+ Edit tools/report.py -> additionalContext:
14
+ "The repository's lint found these problems in tools/report.py ..."
15
+
16
+ A repository takes part only when its origin ``owner/repo`` slug is in
17
+ ``CLAUDE_EDIT_LINT_REPOSITORIES``, it commits ``.claude/edit-lint.json``, and
18
+ its root sits inside the session's project folder, the folder Claude Code's
19
+ workspace trust covers. A checkout elsewhere never runs its declared command.
20
+ The command runs from the repository root, and ``{file}`` in it becomes the
21
+ edited path with every symlink resolved. Its standard output is the findings; empty output adds nothing.
22
+ A command that exits non-zero, times out, or cannot start adds nothing and
23
+ writes one diagnostic line to stderr. The hook never blocks and never edits
24
+ the file.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ try:
37
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
38
+ _blocking_directory = str(Path(_hooks_directory) / "blocking")
39
+ for each_bootstrap_directory in (_hooks_directory, _blocking_directory):
40
+ if each_bootstrap_directory not in sys.path:
41
+ sys.path.insert(0, each_bootstrap_directory)
42
+ from hooks_constants.edit_lint_context_constants import (
43
+ ALL_EDIT_TOOL_NAMES,
44
+ ALL_GIT_TOPLEVEL_COMMAND,
45
+ COMMAND_KEY,
46
+ CONFIG_RELATIVE_PATH,
47
+ FILE_PLACEHOLDER,
48
+ FILE_SUFFIXES_KEY,
49
+ FINDINGS_CONTEXT_TEMPLATE,
50
+ GIT_COMMAND_TIMEOUT_SECONDS,
51
+ LINT_COMMAND_TIMEOUT_SECONDS,
52
+ LINT_FAILURE_DIAGNOSTIC_TEMPLATE,
53
+ MAXIMUM_FINDINGS_CHARACTERS,
54
+ PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE,
55
+ REPOSITORIES_ENVIRONMENT_VARIABLE,
56
+ REPOSITORY_SEPARATOR,
57
+ TRUNCATION_NOTICE,
58
+ )
59
+ from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
60
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
61
+ from hooks_constants.subprocess_window import hidden_window_creation_flags
62
+ from pii_prevention_blocker_parts.repository_exemption import repository_origin_slug
63
+ except ImportError as import_error:
64
+ raise ImportError(
65
+ "edit_lint_context: cannot import its sibling modules; "
66
+ "ensure the hooks directory is importable."
67
+ ) from import_error
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class EditLintConfig:
72
+ """A repository's edit lint settings from ``.claude/edit-lint.json``.
73
+
74
+ Attributes:
75
+ all_file_suffixes: File suffixes the command lints, such as ``.py``.
76
+ all_command_parts: The command and its arguments, with ``{file}``
77
+ where the edited path goes.
78
+ """
79
+
80
+ all_file_suffixes: tuple[str, ...]
81
+ all_command_parts: tuple[str, ...]
82
+
83
+
84
+ def opted_in_repository_slugs() -> frozenset[str]:
85
+ """Return the lowercased ``owner/repo`` slugs the environment value lists.
86
+
87
+ Returns:
88
+ Each non-empty comma-separated entry of
89
+ ``CLAUDE_EDIT_LINT_REPOSITORIES``, stripped and lowercased.
90
+ """
91
+ listed = os.environ.get(REPOSITORIES_ENVIRONMENT_VARIABLE, "")
92
+ return frozenset(
93
+ each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
94
+ )
95
+
96
+
97
+ def repository_root_of(file_path: Path) -> Path | None:
98
+ """Return the git work tree root that holds the file, or None.
99
+
100
+ Args:
101
+ file_path: Absolute path of the edited file.
102
+
103
+ Returns:
104
+ The work tree root, or None when the file sits outside a git work tree
105
+ or git cannot run.
106
+ """
107
+ try:
108
+ completed_process = subprocess.run(
109
+ list(ALL_GIT_TOPLEVEL_COMMAND),
110
+ check=False,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
114
+ cwd=str(file_path.parent),
115
+ creationflags=hidden_window_creation_flags(),
116
+ )
117
+ except (subprocess.TimeoutExpired, OSError):
118
+ return None
119
+ if completed_process.returncode != 0:
120
+ return None
121
+ return Path(completed_process.stdout.strip())
122
+
123
+
124
+ def is_inside_project_directory(repository_root: Path) -> bool:
125
+ """Return whether the repository root sits inside the session's project folder.
126
+
127
+ Args:
128
+ repository_root: The git work tree root.
129
+
130
+ Returns:
131
+ True when ``CLAUDE_PROJECT_DIR`` is set and the resolved root equals it
132
+ or sits below it. False when the variable is unset or the root lies
133
+ outside it.
134
+ """
135
+ project_directory = os.environ.get(PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE, "")
136
+ if not project_directory:
137
+ return False
138
+ resolved_root = repository_root.resolve()
139
+ resolved_project = Path(project_directory).resolve()
140
+ return resolved_root == resolved_project or resolved_project in resolved_root.parents
141
+
142
+
143
+ def load_edit_lint_config(repository_root: Path) -> EditLintConfig | None:
144
+ """Read the repository's ``.claude/edit-lint.json``, or None when unusable.
145
+
146
+ Args:
147
+ repository_root: The git work tree root.
148
+
149
+ Returns:
150
+ The parsed settings, or None when the file is missing, is not JSON, or
151
+ lacks a non-empty string list under ``file_suffixes`` or ``command``.
152
+ """
153
+ try:
154
+ raw_config = json.loads(
155
+ (repository_root / CONFIG_RELATIVE_PATH).read_text(encoding="utf-8")
156
+ )
157
+ except (OSError, ValueError):
158
+ return None
159
+ if not isinstance(raw_config, dict):
160
+ return None
161
+ all_file_suffixes = _string_tuple(raw_config.get(FILE_SUFFIXES_KEY))
162
+ all_command_parts = _string_tuple(raw_config.get(COMMAND_KEY))
163
+ if not all_file_suffixes or not all_command_parts:
164
+ return None
165
+ return EditLintConfig(all_file_suffixes, all_command_parts)
166
+
167
+
168
+ def _string_tuple(raw_value: object) -> tuple[str, ...]:
169
+ if not isinstance(raw_value, list):
170
+ return ()
171
+ if not all(isinstance(each, str) and each for each in raw_value):
172
+ return ()
173
+ return tuple(raw_value)
174
+
175
+
176
+ def lint_findings(config: EditLintConfig, repository_root: Path, file_path: Path) -> str:
177
+ """Run the lint command on one file and return its findings text.
178
+
179
+ Args:
180
+ config: The repository's edit lint settings.
181
+ repository_root: The directory the command runs from.
182
+ file_path: The edited file, put where ``{file}`` stands.
183
+
184
+ Returns:
185
+ The command's standard output, stripped. An empty string when the
186
+ command printed nothing, exited non-zero, timed out, or could not
187
+ start.
188
+ """
189
+ completed_process = _run_lint_command(config, repository_root, file_path)
190
+ if completed_process is None:
191
+ return ""
192
+ if completed_process.returncode != 0:
193
+ last_error_line = (completed_process.stderr.strip().splitlines() or [""])[-1]
194
+ _write_failure_diagnostic(
195
+ file_path, f"exit {completed_process.returncode}: {last_error_line}"
196
+ )
197
+ return ""
198
+ return completed_process.stdout.strip()
199
+
200
+
201
+ def _run_lint_command(
202
+ config: EditLintConfig, repository_root: Path, file_path: Path
203
+ ) -> subprocess.CompletedProcess[str] | None:
204
+ all_arguments = [
205
+ each_part.replace(FILE_PLACEHOLDER, str(file_path))
206
+ for each_part in config.all_command_parts
207
+ ]
208
+ try:
209
+ return subprocess.run(
210
+ all_arguments,
211
+ check=False,
212
+ capture_output=True,
213
+ text=True,
214
+ encoding="utf-8",
215
+ errors="replace",
216
+ timeout=LINT_COMMAND_TIMEOUT_SECONDS,
217
+ cwd=str(repository_root),
218
+ creationflags=hidden_window_creation_flags(),
219
+ )
220
+ except subprocess.TimeoutExpired:
221
+ _write_failure_diagnostic(file_path, "timed out")
222
+ except OSError as start_error:
223
+ _write_failure_diagnostic(file_path, str(start_error))
224
+ return None
225
+
226
+
227
+ def _write_failure_diagnostic(file_path: Path, failure_text: str) -> None:
228
+ sys.stderr.write(LINT_FAILURE_DIAGNOSTIC_TEMPLATE % (file_path, failure_text))
229
+
230
+
231
+ def findings_context(file_label: str, findings_text: str) -> str:
232
+ """Return the note the agent reads, with the findings cut to the size limit.
233
+
234
+ Args:
235
+ file_label: The edited file as the note names it.
236
+ findings_text: The lint command's findings.
237
+
238
+ Returns:
239
+ The note text.
240
+ """
241
+ if len(findings_text) > MAXIMUM_FINDINGS_CHARACTERS:
242
+ findings_text = findings_text[:MAXIMUM_FINDINGS_CHARACTERS] + TRUNCATION_NOTICE.format(
243
+ limit=MAXIMUM_FINDINGS_CHARACTERS
244
+ )
245
+ return FINDINGS_CONTEXT_TEMPLATE.format(file=file_label, findings=findings_text)
246
+
247
+
248
+ def _edited_file_path(all_hook_fields: dict[str, object]) -> Path | None:
249
+ if all_hook_fields.get("tool_name") not in ALL_EDIT_TOOL_NAMES:
250
+ return None
251
+ tool_input = all_hook_fields.get("tool_input")
252
+ if not isinstance(tool_input, dict):
253
+ return None
254
+ raw_path = tool_input.get("file_path")
255
+ if not isinstance(raw_path, str) or not raw_path:
256
+ return None
257
+ file_path = Path(raw_path)
258
+ if not file_path.is_absolute() or not file_path.is_file():
259
+ return None
260
+ return file_path.resolve()
261
+
262
+
263
+ def main() -> None:
264
+ hook_input = read_hook_input_dictionary_from_stdin()
265
+ if hook_input is None:
266
+ return
267
+ all_opted_in_slugs = opted_in_repository_slugs()
268
+ if not all_opted_in_slugs:
269
+ return
270
+ file_path = _edited_file_path(hook_input)
271
+ if file_path is None:
272
+ return
273
+ repository_root = repository_root_of(file_path)
274
+ if repository_root is None or not is_inside_project_directory(repository_root):
275
+ return
276
+ if repository_origin_slug(repository_root) not in all_opted_in_slugs:
277
+ return
278
+ config = load_edit_lint_config(repository_root)
279
+ if config is None or file_path.suffix not in config.all_file_suffixes:
280
+ return
281
+ findings_text = lint_findings(config, repository_root, file_path)
282
+ if not findings_text:
283
+ return
284
+ try:
285
+ file_label = file_path.relative_to(repository_root.resolve()).as_posix()
286
+ except ValueError:
287
+ file_label = str(file_path)
288
+ write_post_tool_use_context_to_stdout(findings_context(file_label, findings_text))
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
@@ -0,0 +1,269 @@
1
+ """Behavior tests for the edit lint context hook.
2
+
3
+ Each test builds a disposable git repository whose origin points at a
4
+ github.com slug and whose ``.claude/edit-lint.json`` names a small lint script.
5
+ The script prints one finding for every line holding ``BAD``. The hook runs
6
+ through the PostToolUse dispatcher the way the live entry runs it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ try:
20
+ advisory_directory = str(Path(__file__).resolve().parent)
21
+ if advisory_directory not in sys.path:
22
+ sys.path.insert(0, advisory_directory)
23
+ import edit_lint_context
24
+ except ImportError as import_error:
25
+ raise ImportError(
26
+ "test_edit_lint_context: cannot import its sibling modules; "
27
+ "ensure the advisory directory is importable."
28
+ ) from import_error
29
+
30
+
31
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
32
+ DISPATCHER_PATH = HOOKS_DIRECTORY / "validation" / "post_tool_use_dispatcher.py"
33
+ HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
34
+ ORIGIN_SLUG = "LintOwner/lint-repo"
35
+ ORIGIN_URL = f"https://github.com/{ORIGIN_SLUG}.git"
36
+ LINT_SCRIPT_SOURCE = (
37
+ "import sys\n"
38
+ "path = sys.argv[1]\n"
39
+ "for number, line in enumerate(open(path, encoding='utf-8'), 1):\n"
40
+ " if 'BAD' in line:\n"
41
+ " print(f'{path}:{number}: F821 undefined name')\n"
42
+ " if 'CRASH' in line:\n"
43
+ " sys.stderr.write('lint tool missing\\n')\n"
44
+ " sys.exit(3)\n"
45
+ )
46
+ DISPATCHER_TIMEOUT_SECONDS = 60
47
+
48
+
49
+ def _git_environment() -> dict[str, str]:
50
+ return {
51
+ each_name: each_value
52
+ for each_name, each_value in os.environ.items()
53
+ if not each_name.upper().startswith("GIT_")
54
+ }
55
+
56
+
57
+ def _run_git(repository_root: Path, *all_arguments: str) -> None:
58
+ subprocess.run(
59
+ ["git", *all_arguments],
60
+ cwd=repository_root,
61
+ check=True,
62
+ capture_output=True,
63
+ env=_git_environment(),
64
+ )
65
+
66
+
67
+ @pytest.fixture
68
+ def lint_repository(tmp_path: Path) -> Path:
69
+ repository_root = tmp_path / "lint-repo"
70
+ repository_root.mkdir()
71
+ _run_git(repository_root, "init", "--quiet")
72
+ _run_git(repository_root, "remote", "add", "origin", ORIGIN_URL)
73
+ (repository_root / "lint.py").write_text(LINT_SCRIPT_SOURCE, encoding="utf-8")
74
+ config_path = repository_root / ".claude" / "edit-lint.json"
75
+ config_path.parent.mkdir()
76
+ config_path.write_text(
77
+ json.dumps({"file_suffixes": [".py"], "command": [sys.executable, "lint.py", "{file}"]}),
78
+ encoding="utf-8",
79
+ )
80
+ return repository_root
81
+
82
+
83
+ def _run_dispatcher(
84
+ tool_name: str,
85
+ file_path: Path,
86
+ listed_repositories: str | None,
87
+ project_directory: Path | None = None,
88
+ ) -> str:
89
+ environment = _git_environment()
90
+ environment["CLAUDE_PROJECT_DIR"] = str(project_directory or file_path.parents[-2])
91
+ environment.pop("CLAUDE_EDIT_LINT_REPOSITORIES", None)
92
+ if listed_repositories is not None:
93
+ environment["CLAUDE_EDIT_LINT_REPOSITORIES"] = listed_repositories
94
+ completed_process = subprocess.run(
95
+ [sys.executable, str(DISPATCHER_PATH)],
96
+ input=json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}}),
97
+ capture_output=True,
98
+ text=True,
99
+ env=environment,
100
+ timeout=DISPATCHER_TIMEOUT_SECONDS,
101
+ check=False,
102
+ )
103
+ assert completed_process.returncode == 0, completed_process.stderr
104
+ return completed_process.stdout.strip()
105
+
106
+
107
+ def _additional_context(dispatcher_output: str) -> str:
108
+ return json.loads(dispatcher_output)["hookSpecificOutput"]["additionalContext"]
109
+
110
+
111
+ def should_add_findings_after_an_edit_in_an_opted_in_repository(lint_repository: Path) -> None:
112
+ edited_file = lint_repository / "module.py"
113
+ edited_file.write_text("VALUE = 1\nRESULT = BAD\n", encoding="utf-8")
114
+
115
+ note = _additional_context(_run_dispatcher("Edit", edited_file, ORIGIN_SLUG.lower()))
116
+
117
+ assert "module.py:2: F821 undefined name" in note
118
+ assert note.startswith("The repository's lint found these problems in module.py")
119
+
120
+
121
+ def should_add_findings_after_a_write_and_a_multi_edit(lint_repository: Path) -> None:
122
+ edited_file = lint_repository / "module.py"
123
+ edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
124
+
125
+ for each_tool_name in ("Write", "MultiEdit"):
126
+ note = _additional_context(_run_dispatcher(each_tool_name, edited_file, ORIGIN_SLUG))
127
+ assert "module.py:1: F821" in note
128
+
129
+
130
+ def should_pass_the_lint_command_the_path_with_symlinks_resolved(
131
+ lint_repository: Path, tmp_path: Path
132
+ ) -> None:
133
+ package_directory = lint_repository / "package"
134
+ package_directory.mkdir()
135
+ (package_directory / "module.py").write_text("RESULT = BAD\n", encoding="utf-8")
136
+ linked_directory = tmp_path / "linked-package"
137
+ linked_directory.symlink_to(package_directory, target_is_directory=True)
138
+
139
+ note = _additional_context(
140
+ _run_dispatcher("Edit", linked_directory / "module.py", ORIGIN_SLUG)
141
+ )
142
+
143
+ assert f"{(package_directory / 'module.py').resolve()}:1: F821" in note
144
+ assert "linked-package" not in note
145
+
146
+
147
+ def should_add_nothing_when_the_environment_lists_no_repository(lint_repository: Path) -> None:
148
+ edited_file = lint_repository / "module.py"
149
+ edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
150
+
151
+ assert _run_dispatcher("Edit", edited_file, None) == ""
152
+ assert _run_dispatcher("Edit", edited_file, "other/repo") == ""
153
+
154
+
155
+ def should_add_nothing_for_a_repository_outside_the_project_folder(
156
+ lint_repository: Path, tmp_path: Path
157
+ ) -> None:
158
+ edited_file = lint_repository / "module.py"
159
+ edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
160
+ other_project = tmp_path / "other-project"
161
+ other_project.mkdir()
162
+
163
+ assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG, other_project) == ""
164
+
165
+
166
+ def should_accept_a_root_inside_the_project_folder(
167
+ lint_repository: Path, monkeypatch: pytest.MonkeyPatch
168
+ ) -> None:
169
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(lint_repository.parent))
170
+ assert edit_lint_context.is_inside_project_directory(lint_repository)
171
+
172
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(lint_repository / "package"))
173
+ assert not edit_lint_context.is_inside_project_directory(lint_repository)
174
+
175
+
176
+ def should_add_nothing_after_a_clean_edit(lint_repository: Path) -> None:
177
+ edited_file = lint_repository / "module.py"
178
+ edited_file.write_text("VALUE = 1\n", encoding="utf-8")
179
+
180
+ assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
181
+
182
+
183
+ def should_add_nothing_for_a_suffix_the_repository_does_not_lint(lint_repository: Path) -> None:
184
+ edited_file = lint_repository / "notes.md"
185
+ edited_file.write_text("BAD\n", encoding="utf-8")
186
+
187
+ assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
188
+
189
+
190
+ def should_add_nothing_when_the_lint_command_fails(lint_repository: Path) -> None:
191
+ edited_file = lint_repository / "module.py"
192
+ edited_file.write_text("RESULT = BAD\nCRASH\n", encoding="utf-8")
193
+
194
+ assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
195
+
196
+
197
+ def should_add_nothing_for_a_file_outside_a_git_repository(tmp_path: Path) -> None:
198
+ loose_file = tmp_path / "loose.py"
199
+ loose_file.write_text("RESULT = BAD\n", encoding="utf-8")
200
+
201
+ assert _run_dispatcher("Edit", loose_file, ORIGIN_SLUG) == ""
202
+
203
+
204
+ def should_register_the_dispatcher_for_edit_write_and_multi_edit() -> None:
205
+ registrations = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
206
+ all_matchers = [
207
+ each_group["matcher"]
208
+ for each_group in registrations["hooks"]["PostToolUse"]
209
+ if any(
210
+ "post_tool_use_dispatcher.py" in each_hook["command"]
211
+ for each_hook in each_group["hooks"]
212
+ )
213
+ ]
214
+
215
+ assert all_matchers == ["Write|Edit|MultiEdit"]
216
+
217
+
218
+ def should_read_listed_slugs_lowercased(monkeypatch: pytest.MonkeyPatch) -> None:
219
+ monkeypatch.setenv("CLAUDE_EDIT_LINT_REPOSITORIES", " Owner/One , ,owner/two")
220
+
221
+ assert edit_lint_context.opted_in_repository_slugs() == frozenset({"owner/one", "owner/two"})
222
+
223
+
224
+ def should_find_the_work_tree_root_of_a_nested_file(lint_repository: Path) -> None:
225
+ nested_directory = lint_repository / "package"
226
+ nested_directory.mkdir()
227
+ nested_file = nested_directory / "inner.py"
228
+ nested_file.write_text("VALUE = 1\n", encoding="utf-8")
229
+
230
+ found_root = edit_lint_context.repository_root_of(nested_file)
231
+
232
+ assert found_root is not None
233
+ assert found_root.resolve() == lint_repository.resolve()
234
+
235
+
236
+ def should_reject_a_config_without_a_command(lint_repository: Path) -> None:
237
+ config_path = lint_repository / ".claude" / "edit-lint.json"
238
+ config_path.write_text(json.dumps({"file_suffixes": [".py"], "command": []}), encoding="utf-8")
239
+
240
+ assert edit_lint_context.load_edit_lint_config(lint_repository) is None
241
+
242
+
243
+ def should_load_the_suffixes_and_command(lint_repository: Path) -> None:
244
+ config = edit_lint_context.load_edit_lint_config(lint_repository)
245
+
246
+ assert config == edit_lint_context.EditLintConfig(
247
+ (".py",), (sys.executable, "lint.py", "{file}")
248
+ )
249
+
250
+
251
+ def should_return_the_lint_output_for_one_file(lint_repository: Path) -> None:
252
+ edited_file = lint_repository / "module.py"
253
+ edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
254
+ config = edit_lint_context.load_edit_lint_config(lint_repository)
255
+ assert config is not None
256
+
257
+ assert edit_lint_context.lint_findings(config, lint_repository, edited_file) == (
258
+ f"{edited_file}:1: F821 undefined name"
259
+ )
260
+
261
+
262
+ def should_cut_long_findings_to_the_limit() -> None:
263
+ long_findings = "x" * 5000
264
+
265
+ note = edit_lint_context.findings_context("module.py", long_findings)
266
+
267
+ assert "x" * 4000 in note
268
+ assert "x" * 4001 not in note
269
+ assert note.endswith("[edit lint output cut at 4000 characters]")
@@ -43,13 +43,32 @@ TOOL_RESULT_ENTRY = {
43
43
  },
44
44
  }
45
45
 
46
+ REFUSED_TOOL_RESULT_ENTRY = {
47
+ "type": "user",
48
+ "message": {
49
+ "content": [
50
+ {
51
+ "type": "tool_result",
52
+ "tool_use_id": "toolu_1",
53
+ "content": "<tool_use_error>Skill verify cannot be used with Skill tool</tool_use_error>",
54
+ "is_error": True,
55
+ }
56
+ ]
57
+ },
58
+ }
59
+
46
60
 
47
61
  def _skill_entry(skill_name: str) -> dict[str, object]:
48
62
  return {
49
63
  "type": "assistant",
50
64
  "message": {
51
65
  "content": [
52
- {"type": "tool_use", "name": "Skill", "input": {"skill": skill_name}}
66
+ {
67
+ "type": "tool_use",
68
+ "id": "toolu_1",
69
+ "name": "Skill",
70
+ "input": {"skill": skill_name},
71
+ }
53
72
  ]
54
73
  },
55
74
  }
@@ -197,6 +216,33 @@ def test_docs_only_commit_passes(repository: Path, tmp_path: Path) -> None:
197
216
  )
198
217
 
199
218
 
219
+ def test_data_files_under_docs_folder_pass(repository: Path, tmp_path: Path) -> None:
220
+ (repository / "docs" / "records").mkdir(parents=True)
221
+ (repository / "docs" / "records" / "proof.json").write_text("{}\n", encoding="utf-8")
222
+ (repository / "docs" / "ruleset.yaml").write_text("rules: []\n", encoding="utf-8")
223
+ _git(repository, "add", "-A")
224
+ assert not _is_denied(
225
+ _payload("git commit -m records", repository, _unverified(tmp_path))
226
+ )
227
+
228
+
229
+ def test_code_under_docs_folder_is_denied(repository: Path, tmp_path: Path) -> None:
230
+ (repository / "docs").mkdir()
231
+ (repository / "docs" / "conf.py").write_text("VALUE = 2\n", encoding="utf-8")
232
+ _git(repository, "add", "-A")
233
+ assert _is_denied(
234
+ _payload("git commit -m conf", repository, _unverified(tmp_path))
235
+ )
236
+
237
+
238
+ def test_json_outside_docs_folder_is_denied(repository: Path, tmp_path: Path) -> None:
239
+ (repository / "config.json").write_text("{}\n", encoding="utf-8")
240
+ _git(repository, "add", "-A")
241
+ assert _is_denied(
242
+ _payload("git commit -m config", repository, _unverified(tmp_path))
243
+ )
244
+
245
+
200
246
  def test_tests_only_commit_passes(repository: Path, tmp_path: Path) -> None:
201
247
  (repository / "test_app.py").write_text(
202
248
  "def test_value() -> None:\n pass\n", encoding="utf-8"
@@ -219,6 +265,15 @@ def test_docs_plus_code_commit_is_denied(repository: Path, tmp_path: Path) -> No
219
265
  )
220
266
 
221
267
 
268
+ def test_refused_verify_skill_call_is_denied(repository: Path, tmp_path: Path) -> None:
269
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
270
+ _git(repository, "add", "app.py")
271
+ transcript_path = _transcript(
272
+ tmp_path, PROMPT_ENTRY, _skill_entry("verify"), REFUSED_TOOL_RESULT_ENTRY
273
+ )
274
+ assert _is_denied(_payload("git commit -m change", repository, transcript_path))
275
+
276
+
222
277
  def test_verify_skill_call_after_the_last_prompt_passes(
223
278
  repository: Path, tmp_path: Path
224
279
  ) -> None:
@@ -33,6 +33,8 @@ from hooks_constants.verify_before_commit_gate_constants import (
33
33
  ALL_ADD_EVERYTHING_OPTIONS,
34
34
  ALL_COMMIT_ALL_OPTIONS,
35
35
  ALL_COMMIT_VALUED_SHORT_FLAGS,
36
+ ALL_DOCS_DATA_EXTENSIONS,
37
+ ALL_DOCS_DIRECTORY_NAMES,
36
38
  ALL_DOCS_EXTENSIONS,
37
39
  ALL_GIT_EXECUTABLES,
38
40
  ALL_SLASH_COMMAND_MARKERS,
@@ -176,7 +178,12 @@ def _is_docs_or_test_path(repository_path: str) -> bool:
176
178
  path = PurePosixPath(repository_path)
177
179
  if path.suffix.lower() in ALL_DOCS_EXTENSIONS or path.name in ALL_TEST_FILE_NAMES:
178
180
  return True
179
- if any(each_part in ALL_TEST_DIRECTORY_NAMES for each_part in path.parts[:-1]):
181
+ all_directory_parts = path.parts[:-1]
182
+ if path.suffix.lower() in ALL_DOCS_DATA_EXTENSIONS and any(
183
+ each_part in ALL_DOCS_DIRECTORY_NAMES for each_part in all_directory_parts
184
+ ):
185
+ return True
186
+ if any(each_part in ALL_TEST_DIRECTORY_NAMES for each_part in all_directory_parts):
180
187
  return True
181
188
  return (
182
189
  path.name.startswith(ALL_TEST_FILE_PREFIXES)
@@ -245,6 +252,9 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
245
252
  git commit, code staged, no verify since the last prompt -> DENY_REASON
246
253
  git add app.py; git commit, empty index, no verify -> DENY_REASON
247
254
  git commit, only docs or test files -> None
255
+ git commit, only data files (.json, .jsonl, .yaml, .yml,
256
+ .csv) under a docs folder -> None
257
+ git commit, a .py file under a docs folder -> DENY_REASON
248
258
  git commit after a verify Skill call or a /verify prompt -> None
249
259
  git commit in a repository with no verify skill directory -> None
250
260
  transcript missing or unreadable, or git fails -> None
@@ -18,7 +18,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
18
18
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
19
19
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
20
20
  - `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
21
- - `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
21
+ - `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call whose result comes back without an error, or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, test files, or `.json`, `.jsonl`, `.yaml`, `.yml`, or `.csv` files under a `docs` folder.
22
22
  - `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
23
23
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes. It also denies a pull request open, through `gh pr create`, `pull_request.py create` or a `create_pull_request` tool, until a pass recorded as `merge_review_receipt.py <owner/name> open <head> 0` covers the head the pull request opens with. A merge pinned to that same head passes on the open receipt.
24
24
  - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
@@ -68,7 +68,7 @@ Preconditions:
68
68
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
69
69
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
70
70
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
71
- - **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a tests-only commit, and a repository with no verify skill pass.
71
+ - **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a commit of data files under a `docs` folder, a tests-only commit, and a repository with no verify skill pass. A `.py` file under `docs` and a `.json` file outside `docs` still deny.
72
72
  - **Eval write.** Input is a Write to `evals/cases.json` with no build-eval call in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_build_eval_before_eval_write_gate.py -q`. The adjacent test observes a denial, then an allow once the call appears; a write outside an eval path passes.
73
73
  - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass. An open with no clean open receipt at its head is denied; an open reviewed clean at its head, and a merge pinned to that head, pass.
74
74
  - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
@@ -1,16 +1,18 @@
1
1
  # Post-write validation
2
2
 
3
- This family checks a completed Write or Edit call and formats eligible new source files. The agent receives a combined block decision if a hosted check blocks.
3
+ This family checks a completed Write, Edit or MultiEdit call, formats eligible new source files, and runs an opted-in repository's lint on the edited file. The agent receives a combined block decision if a hosted check blocks.
4
4
 
5
5
  ## Checks
6
6
 
7
7
  - `validation/post_tool_use_dispatcher.py` runs the post-write roster in order and combines any block reasons.
8
8
  - `workflow/auto_formatter.py` formats eligible untracked source files after a Write and never blocks the write.
9
+ - `advisory/edit_lint_context.py` runs the lint command a repository names in `.claude/edit-lint.json` on the edited file and adds its findings to the agent's context. It runs only for a repository whose origin `owner/repo` slug is listed in `CLAUDE_EDIT_LINT_REPOSITORIES`, comma-separated, and whose root sits inside `CLAUDE_PROJECT_DIR`, the folder the session's workspace trust covers. It never blocks and never edits the file.
9
10
 
10
11
  ## When it fires
11
12
 
12
- - `validation/post_tool_use_dispatcher.py` runs on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds in `hooks.json`.
13
- - `workflow/auto_formatter.py` runs inside that dispatcher on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds, as named by `ALL_POST_HOSTED_HOOK_ENTRIES`. Its eligibility check accepts only a Write of an untracked source file.
13
+ - `validation/post_tool_use_dispatcher.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit`, timeout `180` seconds in `hooks.json`.
14
+ - `workflow/auto_formatter.py` runs inside that dispatcher on `PostToolUse`, matcher `Write|Edit|MultiEdit`, timeout `180` seconds, as named by `ALL_POST_HOSTED_HOOK_ENTRIES`. Its eligibility check accepts only a Write of an untracked source file.
15
+ - `advisory/edit_lint_context.py` runs inside that dispatcher after the formatter, so it lints the file as the formatter left it.
14
16
 
15
17
  ## Proving it
16
18
 
@@ -19,10 +21,12 @@ Preconditions:
19
21
  - Run each command from the repository root with Python and pytest available. The formatter tests make disposable repositories.
20
22
 
21
23
  - **Post-write dispatch.** Input is an Edit of plain text. Run `python -m pytest packages/claude-dev-env/hooks/validation/test_post_tool_use_dispatcher.py -q`. The adjacent test observes an allow result and the hosted formatter's selection.
24
+ - **Edit lint context.** Input is an Edit, Write or MultiEdit of a file in a disposable repository that lists a lint script. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_edit_lint_context.py -q`. The adjacent test observes the findings note for an opted-in repository and no output for a clean edit, an unlisted repository, an unlisted suffix, or a failed lint command.
22
25
  - **Formatter eligibility.** Input is a Write of an untracked source file. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_auto_formatter.py -q`. The adjacent test observes formatting for eligible writes and no edit of a tracked file.
23
26
 
24
27
  ## Gotchas
25
28
 
26
- - The current post-write roster contains only `workflow/auto_formatter.py`. The dispatcher's docstring mentions other hosted work from an older roster, so use the roster for membership.
29
+ - The current post-write roster contains `workflow/auto_formatter.py` and `advisory/edit_lint_context.py`. The dispatcher's docstring mentions other hosted work from an older roster, so use the roster for membership.
27
30
  - The formatter can change the file after the original Write. A later hosted script would read the formatted file because the dispatcher preserves roster order.
28
31
  - Formatter eligibility protects the hook tree and checks whether the file is tracked.
32
+ - The edit lint command's standard output is the findings. A repository's command prints nothing on a clean file and sends progress lines to stderr. A non-zero exit adds no note and writes one diagnostic line to stderr, so the command runs the lint in a mode that exits 0 when it reports findings.
package/hooks/hooks.json CHANGED
@@ -477,7 +477,7 @@
477
477
  ],
478
478
  "PostToolUse": [
479
479
  {
480
- "matcher": "Write|Edit",
480
+ "matcher": "Write|Edit|MultiEdit",
481
481
  "hooks": [
482
482
  {
483
483
  "type": "command",
@@ -0,0 +1,40 @@
1
+ """Constants for the PostToolUse hook that lints each edited file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "REPOSITORIES_ENVIRONMENT_VARIABLE",
7
+ "REPOSITORY_SEPARATOR",
8
+ "PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE",
9
+ "CONFIG_RELATIVE_PATH",
10
+ "FILE_SUFFIXES_KEY",
11
+ "COMMAND_KEY",
12
+ "FILE_PLACEHOLDER",
13
+ "ALL_EDIT_TOOL_NAMES",
14
+ "ALL_GIT_TOPLEVEL_COMMAND",
15
+ "GIT_COMMAND_TIMEOUT_SECONDS",
16
+ "LINT_COMMAND_TIMEOUT_SECONDS",
17
+ "MAXIMUM_FINDINGS_CHARACTERS",
18
+ "TRUNCATION_NOTICE",
19
+ "FINDINGS_CONTEXT_TEMPLATE",
20
+ "LINT_FAILURE_DIAGNOSTIC_TEMPLATE",
21
+ ]
22
+
23
+ REPOSITORIES_ENVIRONMENT_VARIABLE = "CLAUDE_EDIT_LINT_REPOSITORIES"
24
+ REPOSITORY_SEPARATOR = ","
25
+ PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_PROJECT_DIR"
26
+ CONFIG_RELATIVE_PATH = ".claude/edit-lint.json"
27
+ FILE_SUFFIXES_KEY = "file_suffixes"
28
+ COMMAND_KEY = "command"
29
+ FILE_PLACEHOLDER = "{file}"
30
+ ALL_EDIT_TOOL_NAMES = frozenset({"Write", "Edit", "MultiEdit"})
31
+ ALL_GIT_TOPLEVEL_COMMAND = ("git", "rev-parse", "--show-toplevel")
32
+ GIT_COMMAND_TIMEOUT_SECONDS = 10
33
+ LINT_COMMAND_TIMEOUT_SECONDS = 60
34
+ MAXIMUM_FINDINGS_CHARACTERS = 4000
35
+ TRUNCATION_NOTICE = "\n[edit lint output cut at {limit} characters]"
36
+ FINDINGS_CONTEXT_TEMPLATE = (
37
+ "The repository's lint found these problems in {file} after this edit. "
38
+ "Fix them in this turn:\n{findings}"
39
+ )
40
+ LINT_FAILURE_DIAGNOSTIC_TEMPLATE = "[edit_lint_context] lint command for %s did not finish: %s\n"
@@ -58,4 +58,8 @@ ALL_POST_HOSTED_HOOK_ENTRIES: tuple[PostHostedHookEntry, ...] = (
58
58
  script_relative_path="workflow/auto_formatter.py",
59
59
  is_blocking=False,
60
60
  ),
61
+ PostHostedHookEntry(
62
+ script_relative_path="advisory/edit_lint_context.py",
63
+ is_blocking=False,
64
+ ),
61
65
  )
@@ -3,7 +3,7 @@
3
3
  DENY_REASON = (
4
4
  "This repository has a verify skill, and this commit changes code. Invoke the verify "
5
5
  "skill with the Skill tool, run the check it routes you to, then run the same command again. "
6
- "A commit that changes only docs or tests passes without it."
6
+ "A commit that changes only docs, data files under a docs folder, or tests passes without it."
7
7
  )
8
8
  SKILL_NAME = "verify"
9
9
  ALL_SLASH_COMMAND_MARKERS = ("<command-name>/verify</command-name>",)
@@ -47,6 +47,8 @@ ALL_DOCS_EXTENSIONS = frozenset(
47
47
  ".ico",
48
48
  }
49
49
  )
50
+ ALL_DOCS_DIRECTORY_NAMES = frozenset({"docs"})
51
+ ALL_DOCS_DATA_EXTENSIONS = frozenset({".json", ".jsonl", ".yaml", ".yml", ".csv"})
50
52
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests", "test", "__tests__"})
51
53
  ALL_TEST_FILE_PREFIXES = ("test_",)
52
54
  ALL_TEST_FILE_SUFFIXES = ("_test.py",)
@@ -18,9 +18,14 @@ from transcript_skill_scan import (
18
18
  )
19
19
 
20
20
 
21
- def _skill_entry(name: str) -> str:
21
+ def _skill_entry(name: str, tool_use_id: str = "toolu_skill") -> str:
22
22
  return json.dumps(
23
- {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": name}}]}}
23
+ {
24
+ "type": "assistant",
25
+ "message": {
26
+ "content": [{"type": "tool_use", "id": tool_use_id, "name": "Skill", "input": {"skill": name}}]
27
+ },
28
+ }
24
29
  )
25
30
 
26
31
 
@@ -144,12 +149,40 @@ def _prompt_entry(content: object) -> str:
144
149
 
145
150
 
146
151
  def test_skill_since_last_prompt_survives_tool_results_and_skill_bodies() -> None:
147
- tool_result = _prompt_entry([{"type": "tool_result", "content": "Launching skill: verify"}])
152
+ tool_result = _prompt_entry(
153
+ [{"type": "tool_result", "tool_use_id": "toolu_skill", "content": "Launching skill: verify"}]
154
+ )
148
155
  skill_body = json.dumps({"type": "user", "isMeta": True, "message": {"content": "Base directory for this skill"}})
149
156
  lines = [_prompt_entry("Commit it."), _skill_entry("verify"), tool_result, skill_body]
150
157
  assert is_skill_loaded_since_last_prompt(lines, ("verify",), ())
151
158
 
152
159
 
160
+ def test_refused_skill_load_leaves_the_skill_unloaded() -> None:
161
+ refused_result = _prompt_entry(
162
+ [
163
+ {
164
+ "type": "tool_result",
165
+ "tool_use_id": "toolu_skill",
166
+ "content": "<tool_use_error>Skill verify cannot be used with Skill tool</tool_use_error>",
167
+ "is_error": True,
168
+ }
169
+ ]
170
+ )
171
+ lines = [_prompt_entry("Commit it."), _skill_entry("verify"), refused_result]
172
+ assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
173
+
174
+
175
+ def test_skill_call_with_no_result_yet_leaves_the_skill_unloaded() -> None:
176
+ lines = [_prompt_entry("Commit it."), _skill_entry("verify")]
177
+ assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
178
+
179
+
180
+ def test_result_for_another_call_leaves_the_skill_unloaded() -> None:
181
+ other_result = _prompt_entry([{"type": "tool_result", "tool_use_id": "toolu_other", "content": "done"}])
182
+ lines = [_prompt_entry("Commit it."), _skill_entry("verify"), other_result]
183
+ assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
184
+
185
+
153
186
  def test_next_prompt_drops_a_skill_loaded_in_the_last_turn() -> None:
154
187
  lines = [_prompt_entry("First."), _skill_entry("verify"), _prompt_entry([{"type": "text", "text": "Second."}])]
155
188
  assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
@@ -44,6 +44,17 @@ def _named_skill_inputs(
44
44
  all_entry_fields: dict[str, object],
45
45
  skill_names: Collection[str],
46
46
  accepts_plugin_prefix: bool = True,
47
+ ) -> Iterator[dict[str, object]]:
48
+ for each_block in _named_skill_blocks(all_entry_fields, skill_names, accepts_plugin_prefix):
49
+ skill_input = each_block["input"]
50
+ if isinstance(skill_input, dict):
51
+ yield skill_input
52
+
53
+
54
+ def _named_skill_blocks(
55
+ all_entry_fields: dict[str, object],
56
+ skill_names: Collection[str],
57
+ accepts_plugin_prefix: bool = True,
47
58
  ) -> Iterator[dict[str, object]]:
48
59
  message = all_entry_fields.get("message")
49
60
  if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(message, dict):
@@ -61,7 +72,7 @@ def _named_skill_inputs(
61
72
  invoked_name == name or (accepts_plugin_prefix and invoked_name.endswith(":" + name))
62
73
  for name in skill_names
63
74
  ):
64
- yield each_block["input"]
75
+ yield each_block
65
76
 
66
77
 
67
78
  def _invokes_skill(
@@ -183,11 +194,15 @@ def is_skill_loaded_since_last_prompt(
183
194
  ::
184
195
 
185
196
  prompt, Skill verify, tool result, skill body -> True
197
+ prompt, Skill verify, tool result with is_error -> False
198
+ prompt, Skill verify, no tool result yet -> False
186
199
  prompt, Skill verify, prompt -> False
187
200
  prompt carrying the /verify command marker -> True
188
201
  prompt with the marker, compact boundary -> False
189
202
 
190
- A tool result and a meta entry, such as the skill body, leave the turn open.
203
+ A Skill call counts once its tool result comes back without an error, so a
204
+ refused load leaves the skill unloaded. A tool result and a meta entry, such
205
+ as the skill body, leave the turn open.
191
206
 
192
207
  Args:
193
208
  all_transcript_lines: JSON transcript entries, one per line.
@@ -195,13 +210,23 @@ def is_skill_loaded_since_last_prompt(
195
210
  slash_command_markers: Accepted user command tags.
196
211
  """
197
212
  loaded = False
213
+ all_pending_ids: set[object] = set()
198
214
  for each_line in all_transcript_lines:
199
215
  all_entry_fields = _parsed_entry(each_line)
200
216
  if all_entry_fields is None:
201
217
  continue
202
218
  if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE or _is_user_prompt(all_entry_fields):
203
219
  loaded = False
204
- loaded = loaded or _invokes_skill(all_entry_fields, skill_names, slash_command_markers)
220
+ all_pending_ids = set()
221
+ command_text = _user_command_text(all_entry_fields)
222
+ if command_text is not None:
223
+ loaded = loaded or any(marker in command_text for marker in slash_command_markers)
224
+ all_pending_ids.update(
225
+ each_block["id"]
226
+ for each_block in _named_skill_blocks(all_entry_fields, skill_names)
227
+ if each_block.get("id") is not None
228
+ )
229
+ loaded = loaded or bool(all_pending_ids & _succeeded_result_ids(all_entry_fields))
205
230
  return loaded
206
231
 
207
232
 
@@ -366,4 +366,5 @@ def test_live_roster_contains_only_nonblocking_hooks() -> None:
366
366
  assert all(not each_entry.is_blocking for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES)
367
367
  assert tuple(each_entry.script_relative_path for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES) == (
368
368
  "workflow/auto_formatter.py",
369
+ "advisory/edit_lint_context.py",
369
370
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.100.0",
3
+ "version": "8.101.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {