claude-dev-env 8.99.1 → 8.100.1

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.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.99.1",
3
+ "version": "8.100.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,14 @@ clean once the rulings are posted and linked::
13
13
  python3 merge_review_receipt.py owner/name 12 def5678 2 --ruled-out 2 --rulings <url>
14
14
  -> CLEAN owner/name#12 def5678 (2 findings ruled out: <url>)
15
15
 
16
+ A pass run before the pull request exists names ``open`` in place of the
17
+ number, and records the head it reviewed::
18
+
19
+ python3 merge_review_receipt.py owner/name open def5678 0 -> CLEAN owner/name open def5678
20
+
16
21
  ``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
17
- the newest pass only when that pass is clean.
22
+ the newest pass only when that pass is clean. It allows a pull request open
23
+ whose head has a clean ``open`` pass, and a merge pinned to that same head.
18
24
  """
19
25
 
20
26
  from __future__ import annotations
@@ -30,12 +36,19 @@ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
30
36
  if hooks_directory not in sys.path:
31
37
  sys.path.insert(0, hooks_directory)
32
38
 
33
- from blocking.merge_review_gate import is_clean_receipt, receipt_path
39
+ from blocking.merge_review_gate_parts.receipts import (
40
+ is_clean_receipt,
41
+ open_receipt_path,
42
+ receipt_path,
43
+ )
34
44
  from hooks_constants.merge_review_gate_constants import (
45
+ OPEN_RECEIPT_HEAD_PATTERN,
46
+ OPEN_TARGET_WORD,
35
47
  RECEIPT_FINDINGS_KEY,
36
48
  RECEIPT_HEAD_KEY,
37
49
  RECEIPT_RULED_OUT_KEY,
38
50
  RECEIPT_RULINGS_KEY,
51
+ REPOSITORY_SLUG_PATTERN,
39
52
  )
40
53
 
41
54
 
@@ -64,9 +77,29 @@ def record_review(
64
77
  return _write_receipt(slug, number, receipt)
65
78
 
66
79
 
80
+ def record_open_review(slug: str, head: str, finding_count: int) -> Path:
81
+ """Write the receipt for one review pass run before the pull request opens.
82
+
83
+ Args:
84
+ slug: Repository as ``owner/name``.
85
+ head: The commit the pass reviewed.
86
+ finding_count: How many findings the pass reported.
87
+
88
+ Returns:
89
+ The receipt path written.
90
+ """
91
+ owner, repo = slug.split("/", 1)
92
+ return _write_receipt_at(
93
+ open_receipt_path(owner, repo, head), _receipt_fields(head, finding_count, 0, "")
94
+ )
95
+
96
+
67
97
  def _write_receipt(slug: str, number: int, all_receipt_fields: dict[str, object]) -> Path:
68
98
  owner, repo = slug.split("/", 1)
69
- path = receipt_path(owner, repo, number)
99
+ return _write_receipt_at(receipt_path(owner, repo, number), all_receipt_fields)
100
+
101
+
102
+ def _write_receipt_at(path: Path, all_receipt_fields: dict[str, object]) -> Path:
70
103
  path.parent.mkdir(parents=True, exist_ok=True)
71
104
  path.write_text(json.dumps(all_receipt_fields) + "\n", encoding="utf-8")
72
105
  return path
@@ -94,7 +127,9 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
94
127
  """
95
128
  parser = argparse.ArgumentParser(prog="merge_review_receipt")
96
129
  parser.add_argument("slug", help="Repository as owner/name.")
97
- parser.add_argument("number", type=int, help="Pull request number.")
130
+ parser.add_argument(
131
+ "number", help=f"Pull request number, or {OPEN_TARGET_WORD} before the pull request opens."
132
+ )
98
133
  parser.add_argument("head", help="The head commit the pass reviewed.")
99
134
  parser.add_argument("findings", type=int, help="Findings the pass reported.")
100
135
  parser.add_argument(
@@ -103,6 +138,23 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
103
138
  parser.add_argument("--rulings", default="", help="Link to the posted rulings.")
104
139
  parsed = parser.parse_args(all_arguments)
105
140
  rulings = parsed.rulings.strip()
141
+ if not REPOSITORY_SLUG_PATTERN.fullmatch(parsed.slug) or ".." in parsed.slug:
142
+ parser.error("slug must be owner/name")
143
+ if parsed.number == OPEN_TARGET_WORD:
144
+ if parsed.ruled_out or rulings:
145
+ parser.error(f"an {OPEN_TARGET_WORD} pass is clean only at zero findings")
146
+ if parsed.findings < 0:
147
+ parser.error("findings must be zero or more")
148
+ if not OPEN_RECEIPT_HEAD_PATTERN.fullmatch(parsed.head):
149
+ parser.error(f"an {OPEN_TARGET_WORD} pass needs the full 40 character commit")
150
+ record_open_review(parsed.slug, parsed.head, parsed.findings)
151
+ verdict = "CLEAN" if parsed.findings == 0 else "REVIEWED"
152
+ count_note = "" if parsed.findings == 0 else f" {parsed.findings} findings"
153
+ stdout.write(f"{verdict} {parsed.slug} {OPEN_TARGET_WORD} {parsed.head}{count_note}\n")
154
+ return 0 if parsed.findings == 0 else 1
155
+ if not parsed.number.isdigit():
156
+ parser.error(f"number must be a pull request number or {OPEN_TARGET_WORD}")
157
+ parsed.number = int(parsed.number)
106
158
  if parsed.ruled_out < 0 or parsed.ruled_out > parsed.findings:
107
159
  parser.error("--ruled-out must be between 0 and the finding count")
108
160
  if parsed.ruled_out and not rulings:
@@ -122,3 +122,56 @@ def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_req
122
122
  )
123
123
  assert exit_code == 1
124
124
  assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
125
+
126
+
127
+ def test_should_record_a_clean_open_pass_under_heads(receipt_folder: Path) -> None:
128
+ verdict_stream = StringIO()
129
+ assert merge_review_receipt.main(["acme/tools", "open", HEAD, "0"], verdict_stream) == 0
130
+ assert verdict_stream.getvalue() == f"CLEAN acme/tools open {HEAD}\n"
131
+ receipt = json.loads((receipt_folder / "heads" / f"acme__tools__{HEAD}.json").read_text())
132
+ assert receipt == {"head": HEAD, "findings": 0}
133
+
134
+
135
+ def test_record_open_review_writes_the_head_receipt_in_lowercase(receipt_folder: Path) -> None:
136
+ path = merge_review_receipt.record_open_review("Acme/Tools", HEAD.upper(), 2)
137
+ assert path == receipt_folder / "heads" / f"acme__tools__{HEAD}.json"
138
+ assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD.upper(), "findings": 2}
139
+
140
+
141
+ def test_should_exit_one_for_an_open_pass_with_findings() -> None:
142
+ verdict_stream = StringIO()
143
+ assert merge_review_receipt.main(["acme/tools", "open", HEAD, "2"], verdict_stream) == 1
144
+ assert verdict_stream.getvalue() == f"REVIEWED acme/tools open {HEAD} 2 findings\n"
145
+
146
+
147
+ @pytest.mark.parametrize(
148
+ "all_arguments",
149
+ [
150
+ ["acme/tools", "open", HEAD, "-1"],
151
+ ["acme/tools", "open", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
152
+ ["acme/tools", "twelve", HEAD, "0"],
153
+ ],
154
+ )
155
+ def test_should_refuse_an_open_pass_it_cannot_record(
156
+ all_arguments: list[str], receipt_folder: Path
157
+ ) -> None:
158
+ with pytest.raises(SystemExit):
159
+ merge_review_receipt.main(all_arguments, StringIO())
160
+ assert list(receipt_folder.iterdir()) == []
161
+
162
+
163
+ @pytest.mark.parametrize("head", ["abc", "abc1234", "../../escape", "zzzzzzz"])
164
+ def test_should_refuse_an_open_pass_whose_head_is_not_a_commit(head: str, receipt_folder: Path) -> None:
165
+ with pytest.raises(SystemExit):
166
+ merge_review_receipt.main(["acme/tools", "open", head, "0"], StringIO())
167
+ assert list(receipt_folder.iterdir()) == []
168
+
169
+
170
+ @pytest.mark.parametrize("slug", ["owner-repo", "a/b/../c", "a/.."])
171
+ @pytest.mark.parametrize("number", ["open", "12"])
172
+ def test_should_refuse_a_slug_that_is_not_owner_and_name(
173
+ slug: str, number: str, receipt_folder: Path
174
+ ) -> None:
175
+ with pytest.raises(SystemExit):
176
+ merge_review_receipt.main([slug, number, HEAD, "0"], StringIO())
177
+ assert list(receipt_folder.iterdir()) == []