claude-dev-env 8.106.2 → 8.106.4

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.
@@ -62,7 +62,7 @@ Open [details](code-rules/design-and-structure.md) when adding a class or parame
62
62
 
63
63
  ## 7.5 SOLID PRINCIPLES
64
64
 
65
- Apply SRP throughout. Apply OCP, LSP, ISP, and DIP when two concrete implementations share a contract. Keep cohesive classes together.
65
+ Keep a function whole while it reads top to bottom. Split it when the new piece has a second caller or makes the caller easier to read, or when it reaches the 60-line cap the lint and the write-time hook enforce. Apply OCP, LSP, ISP, and DIP when two concrete implementations share a contract. Keep cohesive classes together.
66
66
 
67
67
  Open [details](code-rules/design-and-structure.md) when extracting a responsibility.
68
68
 
@@ -83,6 +83,7 @@ import os
83
83
  import re
84
84
  import shlex
85
85
  import sys
86
+ from collections.abc import Callable, Iterable
86
87
  from pathlib import Path
87
88
 
88
89
  hooks_root_directory = str(Path(__file__).resolve().parent.parent)
@@ -155,6 +156,8 @@ from hooks_constants.reply_length_gate_constants import (
155
156
  EM_DASH_MESSAGE,
156
157
  EM_DASH_PATTERN,
157
158
  FENCED_BLOCK_PATTERN,
159
+ FINDING_SEPARATOR,
160
+ FIX_EVERY_ITEM_INSTRUCTION,
158
161
  ADDITIONAL_CONTEXT_KEY,
159
162
  HOOK_EVENT_NAME,
160
163
  INLINE_CODE_PATTERN,
@@ -207,27 +210,92 @@ def sentence_word_lists(reply_text: str) -> list[list[str]]:
207
210
  return [each_word_list for each_word_list in all_word_lists if each_word_list]
208
211
 
209
212
 
213
+ def joined_violations(all_violations: Iterable[str | None]) -> str | None:
214
+ """Join every violation found into one deny reason, one violation per line.
215
+
216
+ ::
217
+
218
+ [] -> None
219
+ ["Too long."] -> "Too long."
220
+ ["Too long.", "Em dash."] -> "Too long." and "Em dash." on two lines
221
+
222
+ None entries are skipped. A joined reason can join again at an outer
223
+ level and stays one violation per line, so ``numbered_violations``
224
+ numbers the whole reason once.
225
+ """
226
+ all_found = [each_violation for each_violation in all_violations if each_violation]
227
+ return FINDING_SEPARATOR.join(all_found) if all_found else None
228
+
229
+
230
+ def numbered_violations(violation: str) -> str:
231
+ """Number each line of a joined deny reason that holds several violations.
232
+
233
+ ::
234
+
235
+ "Too long." -> "Too long."
236
+ "Too long.\nEm dash." -> "1. Too long." and "2. Em dash." on two lines
237
+ """
238
+ all_lines = violation.split(FINDING_SEPARATOR)
239
+ if len(all_lines) == 1:
240
+ return violation
241
+ return FINDING_SEPARATOR.join(
242
+ f"{each_position}. {each_line}" for each_position, each_line in enumerate(all_lines, start=1)
243
+ )
244
+
245
+
246
+ def collected_violations(all_checks: Iterable[Callable[[], str | None]]) -> str | None:
247
+ """Run every check in order and join each violation it reports.
248
+
249
+ A check that raises ends the run. With nothing found yet the error
250
+ propagates, which lets the call through as an early-return gate did.
251
+ After a finding, the findings so far stand, as the first one did when
252
+ the gate never ran the later checks.
253
+ """
254
+ all_violations: list[str | None] = []
255
+ for each_check in all_checks:
256
+ is_stopped, violation = _checked_violation(each_check, any(all_violations))
257
+ if is_stopped:
258
+ break
259
+ all_violations.append(violation)
260
+ return joined_violations(all_violations)
261
+
262
+
263
+ def _checked_violation(check: Callable[[], str | None], has_finding: bool) -> tuple[bool, str | None]:
264
+ try:
265
+ return False, check()
266
+ except Exception:
267
+ if has_finding:
268
+ return True, None
269
+ raise
270
+
271
+
210
272
  def length_violation(reply_text: str) -> str | None:
211
- """Return the deny reason for an over-long reply, or None when it fits."""
273
+ """Return the deny reason for an over-long reply and each over-long sentence, or None when it fits."""
212
274
  all_sentences = sentence_word_lists(reply_text)
213
- if len(all_sentences) > MAXIMUM_SENTENCE_COUNT:
214
- return TOO_MANY_SENTENCES_MESSAGE.format(
215
- sentence_count=len(all_sentences), sentence_limit=MAXIMUM_SENTENCE_COUNT
216
- )
217
- return long_sentence_violation(reply_text)
275
+ return joined_violations(
276
+ [
277
+ TOO_MANY_SENTENCES_MESSAGE.format(
278
+ sentence_count=len(all_sentences), sentence_limit=MAXIMUM_SENTENCE_COUNT
279
+ )
280
+ if len(all_sentences) > MAXIMUM_SENTENCE_COUNT
281
+ else None,
282
+ long_sentence_violation(reply_text),
283
+ ]
284
+ )
218
285
 
219
286
 
220
287
  def long_sentence_violation(prose_text: str) -> str | None:
221
- """Return the deny reason for the first sentence over the word limit, or None."""
222
- for each_sentence in sentence_word_lists(prose_text):
223
- if len(each_sentence) > MAXIMUM_WORDS_PER_SENTENCE:
224
- sentence_preview = WORD_SEPARATOR.join(each_sentence[:SENTENCE_PREVIEW_WORD_COUNT])
225
- return LONG_SENTENCE_MESSAGE.format(
226
- word_count=len(each_sentence),
227
- word_limit=MAXIMUM_WORDS_PER_SENTENCE,
228
- sentence_preview=sentence_preview + SENTENCE_PREVIEW_SUFFIX,
229
- )
230
- return None
288
+ """Return the deny reason for each sentence over the word limit, or None."""
289
+ return joined_violations(
290
+ LONG_SENTENCE_MESSAGE.format(
291
+ word_count=len(each_sentence),
292
+ word_limit=MAXIMUM_WORDS_PER_SENTENCE,
293
+ sentence_preview=WORD_SEPARATOR.join(each_sentence[:SENTENCE_PREVIEW_WORD_COUNT])
294
+ + SENTENCE_PREVIEW_SUFFIX,
295
+ )
296
+ for each_sentence in sentence_word_lists(prose_text)
297
+ if len(each_sentence) > MAXIMUM_WORDS_PER_SENTENCE
298
+ )
231
299
 
232
300
 
233
301
  def mid_sentence_colon_violation(reply_text: str) -> str | None:
@@ -254,13 +322,13 @@ def prose_sentences(reply_text: str) -> list[str]:
254
322
 
255
323
 
256
324
  def unsourced_cause_violation(reply_text: str) -> str | None:
257
- """Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
258
- for each_sentence in prose_sentences(reply_text):
259
- is_causal = CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
260
- has_evidence = any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
261
- if is_causal and not has_evidence:
262
- return UNSOURCED_CAUSE_MESSAGE.format(sentence=each_sentence.strip())
263
- return None
325
+ """Return the deny reason for each causal sentence that cites no evidence itself, or None."""
326
+ return joined_violations(
327
+ UNSOURCED_CAUSE_MESSAGE.format(sentence=each_sentence.strip())
328
+ for each_sentence in prose_sentences(reply_text)
329
+ if CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
330
+ and not any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
331
+ )
264
332
 
265
333
 
266
334
  def prose_outside_links(reply_text: str) -> str:
@@ -272,11 +340,11 @@ def prose_outside_links(reply_text: str) -> str:
272
340
 
273
341
 
274
342
  def unlinked_pull_request_violation(reply_text: str) -> str | None:
275
- """Return the deny reason for a pull request number outside a link, or None."""
276
- unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(prose_outside_links(reply_text))
277
- if unlinked_match is None:
278
- return None
279
- return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
343
+ """Return the deny reason for each pull request number outside a link, or None."""
344
+ return joined_violations(
345
+ UNLINKED_PULL_REQUEST_MESSAGE.format(reference=each_match.group(0))
346
+ for each_match in UNLINKED_PULL_REQUEST_PATTERN.finditer(prose_outside_links(reply_text))
347
+ )
280
348
 
281
349
 
282
350
  def banned_words_config_path() -> Path:
@@ -303,20 +371,25 @@ def configured_banned_words() -> tuple[str, ...]:
303
371
 
304
372
 
305
373
  def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) -> str | None:
306
- """Return the deny reason for the first banned word in the prose, or None."""
374
+ """Return the deny reason for each banned word in the prose, or None."""
307
375
  prose_text = countable_text(reply_text)
308
- for each_banned_word in all_banned_words:
309
- word_pattern = BANNED_WORD_PART_SEPARATOR.join(
310
- re.escape(each_part) for each_part in each_banned_word.split()
376
+ return joined_violations(
377
+ BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
378
+ for each_banned_word in all_banned_words
379
+ if re.search(
380
+ BANNED_WORD_PATTERN_TEMPLATE.format(
381
+ word_pattern=BANNED_WORD_PART_SEPARATOR.join(
382
+ re.escape(each_part) for each_part in each_banned_word.split()
383
+ )
384
+ ),
385
+ prose_text,
386
+ re.IGNORECASE,
311
387
  )
312
- whole_word_pattern = BANNED_WORD_PATTERN_TEMPLATE.format(word_pattern=word_pattern)
313
- if re.search(whole_word_pattern, prose_text, re.IGNORECASE):
314
- return BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
315
- return None
388
+ )
316
389
 
317
390
 
318
391
  def unvalidated_claim_violation(reply_text: str) -> str | None:
319
- """Return the deny reason quoting the first sentence that guesses or admits a skipped check, or None.
392
+ """Return the deny reason quoting each sentence that guesses or admits a skipped check, or None.
320
393
 
321
394
  A duration counts as a guess unless its sentence cites the run, job, check,
322
395
  or pull request that measured it::
@@ -324,6 +397,7 @@ def unvalidated_claim_violation(reply_text: str) -> str | None:
324
397
  flag: It should take about 40 minutes.
325
398
  ok: The last queue build took 39 minutes, run 18239174012.
326
399
  """
400
+ all_claims: list[str] = []
327
401
  for each_sentence in prose_sentences(reply_text):
328
402
  prose_text = countable_text(each_sentence).replace(CURLY_APOSTROPHE, STRAIGHT_APOSTROPHE)
329
403
  is_guess = UNVALIDATED_CLAIM_PATTERN.search(prose_text) is not None
@@ -332,12 +406,12 @@ def unvalidated_claim_violation(reply_text: str) -> str | None:
332
406
  and CITED_NUMBER_PATTERN.search(each_sentence) is None
333
407
  )
334
408
  if is_guess or is_uncited_duration:
335
- return UNVALIDATED_CLAIM_MESSAGE.format(claim=each_sentence)
336
- return None
409
+ all_claims.append(UNVALIDATED_CLAIM_MESSAGE.format(claim=each_sentence))
410
+ return joined_violations(all_claims)
337
411
 
338
412
 
339
413
  def unseen_citation_violation(reply_text: str, transcript_path: object) -> str | None:
340
- """Return the deny reason for the first cited run, job, check, or pull request no tool result shows.
414
+ """Return the deny reason for each cited run, job, check, or pull request no tool result shows.
341
415
 
342
416
  Returns None when the text cites nothing or the transcript cannot be read.
343
417
  """
@@ -347,11 +421,17 @@ def unseen_citation_violation(reply_text: str, transcript_path: object) -> str |
347
421
  result_text = successful_result_text(transcript_path)
348
422
  if result_text is None:
349
423
  return None
350
- for each_match in all_citations:
351
- cited_number = next(each_group for each_group in each_match.groups() if each_group)
352
- if re.search(CITED_NUMBER_IN_RESULT_TEMPLATE.format(number=cited_number), result_text) is None:
353
- return UNSEEN_CITATION_MESSAGE.format(citation=each_match.group(0))
354
- return None
424
+ return joined_violations(
425
+ UNSEEN_CITATION_MESSAGE.format(citation=each_match.group(0))
426
+ for each_match in all_citations
427
+ if re.search(
428
+ CITED_NUMBER_IN_RESULT_TEMPLATE.format(
429
+ number=next(each_group for each_group in each_match.groups() if each_group)
430
+ ),
431
+ result_text,
432
+ )
433
+ is None
434
+ )
355
435
 
356
436
 
357
437
  def all_card_texts(
@@ -382,25 +462,25 @@ def card_length_violation(all_card_input: dict[str, object]) -> str | None:
382
462
 
383
463
 
384
464
  def card_shape_violation(all_card_input: dict[str, object]) -> str | None:
385
- """Return the deny reason for a listed or over-long card context or card sentence, or None."""
465
+ """Return the deny reason for each listed or over-long card context and card sentence, or None."""
386
466
  context_text = all_card_input.get(DECISION_CARD_CONTEXT_KEY)
467
+ all_violations: list[str | None] = []
387
468
  if isinstance(context_text, str):
388
469
  if CARD_LIST_ITEM_PATTERN.search(FENCED_BLOCK_PATTERN.sub(" ", context_text)) is not None:
389
- return CARD_CONTEXT_LIST_MESSAGE
470
+ all_violations.append(CARD_CONTEXT_LIST_MESSAGE)
390
471
  context_sentence_count = len(sentence_word_lists(context_text))
391
472
  if context_sentence_count > MAXIMUM_CARD_CONTEXT_SENTENCE_COUNT:
392
- return CARD_CONTEXT_TOO_LONG_MESSAGE.format(
393
- sentence_count=context_sentence_count,
394
- sentence_limit=MAXIMUM_CARD_CONTEXT_SENTENCE_COUNT,
473
+ all_violations.append(
474
+ CARD_CONTEXT_TOO_LONG_MESSAGE.format(
475
+ sentence_count=context_sentence_count,
476
+ sentence_limit=MAXIMUM_CARD_CONTEXT_SENTENCE_COUNT,
477
+ )
395
478
  )
396
479
  all_length_checked_texts = all_card_texts(
397
480
  all_card_input, ALL_DECISION_CARD_LENGTH_KEYS, ALL_DECISION_OPTION_LENGTH_KEYS
398
481
  )
399
- for each_text in all_length_checked_texts:
400
- violation = long_sentence_violation(each_text)
401
- if violation is not None:
402
- return violation
403
- return None
482
+ all_violations.extend(long_sentence_violation(each_text) for each_text in all_length_checked_texts)
483
+ return joined_violations(all_violations)
404
484
 
405
485
 
406
486
  def all_question_card_texts(all_card_input: dict[str, object]) -> list[str]:
@@ -429,37 +509,41 @@ def active_mode_rules() -> VisualReplyRules | None:
429
509
 
430
510
 
431
511
  def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
432
- """Return the deny reason for a decision card's prose, or None."""
433
- return (
434
- banned_word_violation(card_text, configured_banned_words())
435
- or unvalidated_claim_violation(card_text)
436
- ) or (
437
- None if mode_rules is None else abbreviation_violation(prose_outside_links(card_text), mode_rules)
512
+ """Return the deny reason for each problem in a decision card's prose, or None."""
513
+ return collected_violations(
514
+ [
515
+ lambda: banned_word_violation(card_text, configured_banned_words()),
516
+ lambda: unvalidated_claim_violation(card_text),
517
+ lambda: None
518
+ if mode_rules is None
519
+ else abbreviation_violation(prose_outside_links(card_text), mode_rules),
520
+ ]
438
521
  )
439
522
 
440
523
 
441
524
  def reply_violation(
442
525
  reply_text: str, transcript_path: object, mode_rules: VisualReplyRules | None
443
526
  ) -> str | None:
444
- """Return the deny reason for a reply's text, or None."""
445
- violation = (
446
- length_violation(reply_text)
447
- or mid_sentence_colon_violation(reply_text)
448
- or em_dash_violation(reply_text)
449
- or unlinked_pull_request_violation(reply_text)
450
- or unsourced_cause_violation(reply_text)
451
- or banned_word_violation(reply_text, configured_banned_words())
452
- or unvalidated_claim_violation(reply_text)
453
- or unseen_citation_violation(reply_text, transcript_path)
454
- )
455
- if violation is not None or mode_rules is None:
456
- return violation
457
- prose_text = prose_outside_links(reply_text)
458
- return (
459
- abbreviation_violation(prose_text, mode_rules)
460
- or second_reply_violation(transcript_path, mode_rules)
461
- or visual_violation(len(sentence_word_lists(reply_text)), transcript_path, mode_rules)
462
- )
527
+ """Return the deny reason for each problem in a reply's text, or None."""
528
+ all_checks: list[Callable[[], str | None]] = [
529
+ lambda: length_violation(reply_text),
530
+ lambda: mid_sentence_colon_violation(reply_text),
531
+ lambda: em_dash_violation(reply_text),
532
+ lambda: unlinked_pull_request_violation(reply_text),
533
+ lambda: unsourced_cause_violation(reply_text),
534
+ lambda: banned_word_violation(reply_text, configured_banned_words()),
535
+ lambda: unvalidated_claim_violation(reply_text),
536
+ lambda: unseen_citation_violation(reply_text, transcript_path),
537
+ ]
538
+ if mode_rules is not None:
539
+ all_checks.extend(
540
+ [
541
+ lambda: abbreviation_violation(prose_outside_links(reply_text), mode_rules),
542
+ lambda: second_reply_violation(transcript_path, mode_rules),
543
+ lambda: visual_violation(len(sentence_word_lists(reply_text)), transcript_path, mode_rules),
544
+ ]
545
+ )
546
+ return collected_violations(all_checks)
463
547
 
464
548
 
465
549
  def tool_violation(
@@ -467,26 +551,30 @@ def tool_violation(
467
551
  ) -> tuple[str, str] | None:
468
552
  """Return the deny reason and the checked text for one call, or None when it passes."""
469
553
  mode_rules = active_mode_rules()
470
- if isinstance(tool_name, str) and mode_rules is not None:
471
- anchor_violation = widget_anchor_violation(tool_name, all_tool_input)
472
- if anchor_violation is not None:
473
- return anchor_violation, str(all_tool_input)
554
+ anchor_violation = (
555
+ widget_anchor_violation(tool_name, all_tool_input)
556
+ if isinstance(tool_name, str) and mode_rules is not None
557
+ else None
558
+ )
559
+ if anchor_violation is None:
560
+ return _text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
561
+ try:
562
+ checked = _text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
563
+ except Exception:
564
+ checked = None
565
+ if checked is None:
566
+ return anchor_violation, str(all_tool_input)
567
+ return FINDING_SEPARATOR.join([anchor_violation, checked[0]]), checked[1]
568
+
569
+
570
+ def _text_violation(
571
+ tool_name: object,
572
+ all_tool_input: dict[str, object],
573
+ transcript_path: object,
574
+ mode_rules: VisualReplyRules | None,
575
+ ) -> tuple[str, str] | None:
474
576
  if tool_name in (DECISION_CARD_TOOL_NAME, QUESTION_CARD_TOOL_NAME):
475
- if tool_name == DECISION_CARD_TOOL_NAME:
476
- all_texts = all_card_texts(
477
- all_tool_input, ALL_DECISION_CARD_PROSE_KEYS, ALL_DECISION_OPTION_PROSE_KEYS
478
- )
479
- shape_violation = card_length_violation(all_tool_input)
480
- else:
481
- all_texts = all_question_card_texts(all_tool_input)
482
- shape_violation = None
483
- card_text = CARD_TEXT_SEPARATOR.join(all_texts)
484
- violation = (
485
- shape_violation
486
- or card_violation(card_text, mode_rules)
487
- or unseen_citation_violation(card_text, transcript_path)
488
- )
489
- return None if violation is None else (violation, card_text)
577
+ return _card_text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
490
578
  if tool_name not in ALL_CHECKED_TOOL_NAMES:
491
579
  return None
492
580
  reply_text = all_tool_input.get(TEXT_KEY)
@@ -496,6 +584,29 @@ def tool_violation(
496
584
  return None if violation is None else (violation, reply_text)
497
585
 
498
586
 
587
+ def _card_text_violation(
588
+ tool_name: object,
589
+ all_tool_input: dict[str, object],
590
+ transcript_path: object,
591
+ mode_rules: VisualReplyRules | None,
592
+ ) -> tuple[str, str] | None:
593
+ if tool_name == DECISION_CARD_TOOL_NAME:
594
+ all_texts = all_card_texts(all_tool_input, ALL_DECISION_CARD_PROSE_KEYS, ALL_DECISION_OPTION_PROSE_KEYS)
595
+ shape_violation = card_length_violation(all_tool_input)
596
+ else:
597
+ all_texts = all_question_card_texts(all_tool_input)
598
+ shape_violation = None
599
+ card_text = CARD_TEXT_SEPARATOR.join(all_texts)
600
+ violation = collected_violations(
601
+ [
602
+ lambda: shape_violation,
603
+ lambda: card_violation(card_text, mode_rules),
604
+ lambda: unseen_citation_violation(card_text, transcript_path),
605
+ ]
606
+ )
607
+ return None if violation is None else (violation, card_text)
608
+
609
+
499
610
  def github_comment_texts(tool_name: object, all_tool_input: dict[str, object]) -> list[str]:
500
611
  """Return the GitHub comment texts one call would post, or an empty list.
501
612
 
@@ -569,15 +680,21 @@ def comment_length_violation(comment_text: str) -> str | None:
569
680
 
570
681
 
571
682
  def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
572
- """Return the deny reason and text for an over-long GitHub comment, or None.
683
+ """Return the deny reason and text for every over-long GitHub comment, or None.
573
684
 
574
685
  Fenced code blocks and collapsed ``<details>`` blocks carry no words.
575
686
  """
576
- for each_comment_text in github_comment_texts(tool_name, all_tool_input):
577
- violation = comment_length_violation(each_comment_text)
578
- if violation is not None:
579
- return violation, each_comment_text
580
- return None
687
+ all_over_long = [
688
+ (violation, each_comment_text)
689
+ for each_comment_text in github_comment_texts(tool_name, all_tool_input)
690
+ if (violation := comment_length_violation(each_comment_text)) is not None
691
+ ]
692
+ if not all_over_long:
693
+ return None
694
+ return (
695
+ FINDING_SEPARATOR.join(each_violation for each_violation, _ in all_over_long),
696
+ CARD_TEXT_SEPARATOR.join(each_text for _, each_text in all_over_long),
697
+ )
581
698
 
582
699
 
583
700
  def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
@@ -607,7 +724,13 @@ def main() -> int:
607
724
  write_flowchart_advice(tool_name, tool_input)
608
725
  return ALLOW_EXIT_CODE
609
726
  violation, reply_text = checked
610
- block_reason = violation + RETRY_INSTRUCTION
727
+ numbered_violation = numbered_violations(violation)
728
+ instruction = RETRY_INSTRUCTION + FIX_EVERY_ITEM_INSTRUCTION
729
+ block_reason = (
730
+ numbered_violation + FINDING_SEPARATOR + instruction.lstrip()
731
+ if FINDING_SEPARATOR in numbered_violation
732
+ else numbered_violation + instruction
733
+ )
611
734
  log_hook_block(
612
735
  Path(__file__).name,
613
736
  HOOK_EVENT_NAME,
@@ -1076,3 +1076,80 @@ def test_should_report_an_over_long_comment_text_with_the_comment_instruction()
1076
1076
  def test_should_pass_a_comment_text_whose_long_part_sits_in_a_details_block() -> None:
1077
1077
  comment_text = "Fixed.\n<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
1078
1078
  assert reply_length_gate.comment_length_violation(comment_text) is None
1079
+
1080
+
1081
+ def test_joined_violations_should_put_each_violation_on_its_own_line() -> None:
1082
+ assert reply_length_gate.joined_violations(["Too long.", None, "Em dash.\nNo link."]) == (
1083
+ "Too long.\nEm dash.\nNo link."
1084
+ )
1085
+
1086
+
1087
+ def test_numbered_violations_should_number_each_line_once() -> None:
1088
+ assert reply_length_gate.numbered_violations("Too long.\nEm dash.\nNo link.") == (
1089
+ "1. Too long.\n2. Em dash.\n3. No link."
1090
+ )
1091
+ assert reply_length_gate.numbered_violations("Too long.") == "Too long."
1092
+
1093
+
1094
+ def test_joined_violations_should_keep_one_violation_unchanged() -> None:
1095
+ assert reply_length_gate.joined_violations([None, "Too long."]) == "Too long."
1096
+
1097
+
1098
+ def test_collected_violations_should_keep_findings_made_before_a_check_raises() -> None:
1099
+ def broken_check() -> str | None:
1100
+ raise TypeError("malformed input")
1101
+
1102
+ assert reply_length_gate.collected_violations([lambda: "Too long.", broken_check]) == "Too long."
1103
+
1104
+
1105
+ def test_collected_violations_should_keep_findings_when_a_later_check_raises_any_error() -> None:
1106
+ def broken_check() -> str | None:
1107
+ raise RuntimeError("transcript unreadable")
1108
+
1109
+ assert reply_length_gate.collected_violations([lambda: "Too long.", broken_check]) == "Too long."
1110
+
1111
+
1112
+ def test_collected_violations_should_raise_when_a_check_breaks_before_any_finding() -> None:
1113
+ def broken_check() -> str | None:
1114
+ raise TypeError("malformed input")
1115
+
1116
+ with pytest.raises(TypeError):
1117
+ reply_length_gate.collected_violations([lambda: None, broken_check])
1118
+
1119
+
1120
+ def test_should_name_every_broken_rule_in_one_refusal(
1121
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1122
+ ) -> None:
1123
+ exit_code, error_text = run_gate(
1124
+ monkeypatch,
1125
+ capsys,
1126
+ POST_TOOL_NAME,
1127
+ {"text": f"See PR 7452 and PR 7460.\n{SIXTEEN_WORD_SENTENCE}\nIt failed — again."},
1128
+ )
1129
+
1130
+ assert exit_code == reply_length_gate.BLOCK_EXIT_CODE
1131
+ assert "PR 7452" in error_text
1132
+ assert "PR 7460" in error_text
1133
+ assert "16 words" in error_text
1134
+ assert reply_length_gate.EM_DASH_MESSAGE in error_text
1135
+ assert error_text.endswith(reply_length_gate.FIX_EVERY_ITEM_INSTRUCTION)
1136
+
1137
+
1138
+ def test_should_number_nested_violations_once_in_one_refusal(
1139
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1140
+ ) -> None:
1141
+ exit_code, error_text = run_gate(
1142
+ monkeypatch,
1143
+ capsys,
1144
+ POST_TOOL_NAME,
1145
+ {"text": f"One.\nTwo.\nThree.\n{SIXTEEN_WORD_SENTENCE}\n{SIXTEEN_WORD_SENTENCE}\nIt failed — again."},
1146
+ )
1147
+
1148
+ assert exit_code == reply_length_gate.BLOCK_EXIT_CODE
1149
+ all_numbered_lines = [each_line for each_line in error_text.splitlines() if each_line[:1].isdigit()]
1150
+ assert [each_line.split(". ", 1)[0] for each_line in all_numbered_lines] == ["1", "2", "3", "4"]
1151
+ assert not any(each_line.split(". ", 1)[1][:1].isdigit() for each_line in all_numbered_lines)
1152
+ assert "Reply too long: 6 sentences" in all_numbered_lines[0]
1153
+ assert all_numbered_lines[1].count("16 words") == 1
1154
+ assert all_numbered_lines[2].count("16 words") == 1
1155
+ assert reply_length_gate.EM_DASH_MESSAGE in all_numbered_lines[3]
@@ -4,11 +4,11 @@ This family stops a chat reply, edited message, or tool call when its payload br
4
4
 
5
5
  ## Checks
6
6
 
7
- - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
7
+ - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. One refusal names every broken rule and every offending sentence, numbered, and ends with "Fix every item above in your next write." With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
8
8
  - `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
9
9
  - `blocking/open_tasks_stop_gate.py` holds the end of a turn once when the prompt that started it asks for work, the turn ran a work tool, and the turn made no tracker call: TaskCreate, TaskUpdate, TodoWrite, the Codex `update_plan`, or the desktop `mcp__ccd_turn__update_plan`. A work tool is any tool but a tracker call, the session title tool, `AskUserQuestion`, a hearthbot decision card, Codex `request_user_input`, `ToolSearch`, `TaskList`, or `TaskGet`. It never reads the reply text. `open_task_asks.is_ask` decides whether a prompt asks for work: a question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, a prompt of only `<system-reminder>` blocks, and an envelope such as `<agent-message>` or `<task-notification>` do not. `blocking/open_tasks_transcript.py` replays the transcript for it: the turn's prompt, its tool calls, and the task list. `session_task_items` merges the task store with the transcript items whose subject the store does not hold, for the session title gate.
10
10
  - `session/tracking_nudge.py` adds one `additionalContext` note to a prompt that asks for work. The note asks for a task list item. When the title shows the red flag or the check mark, it also asks for the hourglass before other work. It never blocks.
11
- - The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
11
+ - The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies every claim, numbered in one refusal, about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
12
12
  - `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
13
13
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
14
14
  - `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
@@ -63,7 +63,7 @@ Preconditions:
63
63
  - **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
64
64
  - **Open tasks.** Input is a transcript whose last prompt asks for work and whose turn ran a Bash call and no tracker call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes one `block` decision; the same turn with a TaskCreate, TaskUpdate, TodoWrite, or desktop plan call passes. A question, an acknowledgement, a `<task-notification>` envelope, a tool-free turn, and a held stop pass. The same file covers Codex and Cursor payloads.
65
65
  - **Ask nudge.** Input is a `UserPromptSubmit` payload with a work prompt, a question, and a `<task-notification>` envelope, after a transcript whose title shows the red flag. Run `python -m pytest packages/claude-dev-env/hooks/session/test_tracking_nudge.py -q`. The adjacent test observes a task list note and a title note for the work prompt, and empty stdout for the other two.
66
- - **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
66
+ - **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Facts with no source: 1. ...`, numbering every failing claim. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
67
67
  - **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
68
68
  - **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
69
69
  - **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
@@ -4,7 +4,7 @@ This family runs nested checkout hooks, refreshes a worktree base, and clears st
4
4
 
5
5
  ## Checks
6
6
 
7
- - `lifecycle/nested_project_hooks.py` runs hook registrations from checkouts nested one level below a session directory.
7
+ - `lifecycle/nested_project_hooks.py` runs hook registrations from checkouts nested one level below a session directory. When several nested hooks refuse a tool call, the one refusal it returns carries every refusing hook's reason.
8
8
  - `lifecycle/enter_worktree_origin_prefetch.py` fetches the default branch before a fresh worktree is created.
9
9
  - `lifecycle/session_end_cleanup.py` removes old context cache files, temporary files, and transcript backups at session end.
10
10
  - `session/plugin_data_dir_cleanup.py` removes empty plugin data directories at session start.
@@ -34,7 +34,7 @@ Preconditions:
34
34
 
35
35
  ## Gotchas
36
36
 
37
- - The nested checkout runner forwards child denials and asks. It drops child allow decisions so a child cannot widen permission.
37
+ - The nested checkout runner forwards child denials and asks, a denial ahead of an ask. It drops child allow decisions so a child cannot widen permission.
38
38
  - Worktree prefetch applies to fresh creation and exits zero after a failed fetch.
39
39
  - Session-end cleanup has no adjacent behavior test. Its direct probe calls `purge_old_entries` with an isolated directory.
40
40
  - The session edit tracker survives a compact or resume. Cleanup targets the current session only.
package/hooks/hooks.json CHANGED
@@ -182,7 +182,7 @@
182
182
  "hooks": [
183
183
  {
184
184
  "type": "prompt",
185
- "prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Fact with no source: <the first failing claim>. Put the file:line, the quoted output, or the link that shows it in the message, run the check first, or leave the claim out.\"}",
185
+ "prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Facts with no source: 1. <first failing claim> 2. <second failing claim> ... For each, put the file:line, the quoted output, or the link that shows it in the message, run the check first, or leave the claim out. Fix every item above in your next write.\"} List every failing claim, numbered in message order, never only the first.",
186
186
  "timeout": 30,
187
187
  "continueOnBlock": true,
188
188
  "statusMessage": "claude-dev-env: checking each claim for its source"
@@ -6,10 +6,12 @@ __all__ = [
6
6
  "HOOK_SPECIFIC_OUTPUT_KEY",
7
7
  "HOOK_EVENT_NAME_KEY",
8
8
  "PERMISSION_DECISION_KEY",
9
+ "PERMISSION_DECISION_REASON_KEY",
9
10
  "UPDATED_INPUT_KEY",
10
11
  ]
11
12
 
12
13
  HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
13
14
  HOOK_EVENT_NAME_KEY: str = "hookEventName"
14
15
  PERMISSION_DECISION_KEY: str = "permissionDecision"
16
+ PERMISSION_DECISION_REASON_KEY: str = "permissionDecisionReason"
15
17
  UPDATED_INPUT_KEY: str = "updatedInput"
@@ -31,6 +31,8 @@ ALL_SHELL_PROGRAM_NAMES: tuple[str, ...] = ("bash", "sh")
31
31
  SHELL_COMMAND_FLAG: str = "-c"
32
32
 
33
33
  BLOCKING_EXIT_CODE: int = 2
34
- ALL_FORWARDED_PERMISSION_DECISIONS: frozenset[str] = frozenset({"deny", "ask"})
34
+ DENY_PERMISSION_DECISION: str = "deny"
35
+ ASK_PERMISSION_DECISION: str = "ask"
36
+ ALL_FORWARDED_PERMISSION_DECISIONS: tuple[str, ...] = (DENY_PERMISSION_DECISION, ASK_PERMISSION_DECISION)
35
37
  ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
36
38
  CONTEXT_SECTION_SEPARATOR: str = "\n\n"
@@ -44,6 +44,8 @@ SENTENCE_PREVIEW_WORD_COUNT = 6
44
44
  WORD_SEPARATOR = " "
45
45
  SENTENCE_PREVIEW_SUFFIX = "..."
46
46
  RETRY_INSTRUCTION = " Cut the text and resend the call."
47
+ FIX_EVERY_ITEM_INSTRUCTION = " Fix every item above in your next write."
48
+ FINDING_SEPARATOR = "\n"
47
49
  TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
48
50
  CARD_CONTEXT_TOO_LONG_MESSAGE = (
49
51
  "Card context too long: {sentence_count} sentences, limit {sentence_limit}."
@@ -13,5 +13,6 @@ def test_keys_match_the_harness_hook_specific_output_field_names() -> None:
13
13
  "HOOK_SPECIFIC_OUTPUT_KEY": "hookSpecificOutput",
14
14
  "HOOK_EVENT_NAME_KEY": "hookEventName",
15
15
  "PERMISSION_DECISION_KEY": "permissionDecision",
16
+ "PERMISSION_DECISION_REASON_KEY": "permissionDecisionReason",
16
17
  "UPDATED_INPUT_KEY": "updatedInput",
17
18
  }
@@ -66,6 +66,7 @@ RESULT_TEXT_SEPARATOR = "\n"
66
66
 
67
67
  ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
68
68
  TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
69
+ VIOLATION_LINE_SEPARATOR = "\n"
69
70
  NO_VISUAL_MESSAGE = "This reply has {sentence_count} sentences and no visual this turn. {reminder}"
70
71
  SECOND_REPLY_MESSAGE = "A reply already went out this turn. {reminder}"
71
72
  NOT_FLOWCHART_MESSAGE = "This widget is not a flowchart. {reminder}"
@@ -13,7 +13,8 @@ hooks, and tool guards then never run. This hook runs them::
13
13
 
14
14
  Each child hook receives the same stdin payload, runs inside its checkout, and
15
15
  keeps its own matcher and timeout. A child ``exit 2`` blocks the tool call and a
16
- child ``deny`` or ``ask`` decision passes through; a child ``allow`` is dropped,
16
+ child ``deny``, else a child ``ask``, passes through, a ``deny`` carrying every
17
+ refusing child's reason; a child ``allow`` is dropped,
17
18
  so no child widens permissions for the session. The ``additionalContext`` of
18
19
  every child, at SessionStart, UserPromptSubmit, and PreToolUse, joins into one
19
20
  ``additionalContext``.
@@ -48,6 +49,7 @@ from hooks_constants.nested_project_hooks_constants import (
48
49
  COMMAND_KEY,
49
50
  CONTEXT_SECTION_SEPARATOR,
50
51
  DEFAULT_HOOK_TIMEOUT_SECONDS,
52
+ DENY_PERMISSION_DECISION,
51
53
  HOOK_EVENT_NAME_KEY,
52
54
  HOOK_TYPE_KEY,
53
55
  HOOKS_KEY,
@@ -67,6 +69,7 @@ from hooks_constants.hook_specific_output_keys import (
67
69
  HOOK_EVENT_NAME_KEY as HOOK_EVENT_NAME_OUTPUT_KEY,
68
70
  HOOK_SPECIFIC_OUTPUT_KEY,
69
71
  PERMISSION_DECISION_KEY,
72
+ PERMISSION_DECISION_REASON_KEY,
70
73
  )
71
74
  from hooks_constants.subprocess_window import hidden_window_creation_flags
72
75
 
@@ -276,41 +279,78 @@ def _permission_decision(all_output_fields: dict[str, object]) -> object:
276
279
  return specific_output.get(PERMISSION_DECISION_KEY)
277
280
 
278
281
 
282
+ def _deny_reason(all_output_fields: dict[str, object]) -> str:
283
+ if _permission_decision(all_output_fields) != DENY_PERMISSION_DECISION:
284
+ return ""
285
+ specific_output = all_output_fields.get(HOOK_SPECIFIC_OUTPUT_KEY)
286
+ reason = (
287
+ specific_output.get(PERMISSION_DECISION_REASON_KEY)
288
+ if isinstance(specific_output, dict)
289
+ else None
290
+ )
291
+ return reason.strip() if isinstance(reason, str) else ""
292
+
293
+
294
+ def _joined_reasons(all_reasons: Iterable[str]) -> str:
295
+ return CONTEXT_SECTION_SEPARATOR.join(dict.fromkeys(_non_empty_sections(all_reasons)))
296
+
297
+
279
298
  def pre_tool_use_verdict(
280
299
  all_results: list[subprocess.CompletedProcess[str]],
281
300
  ) -> HookVerdict:
282
301
  """Fold nested PreToolUse results into the one verdict Claude Code reads.
283
302
 
303
+ Claude Code shows one reason per refused call, so every refusing child's
304
+ reason travels in that one reason::
305
+
306
+ alpha exits 2 "no tests", beta denies "no link" -> exit 2, both reasons
307
+ alpha denies "no link", beta denies "too long" -> deny, both reasons
308
+ alpha asks, beta denies "no link" -> deny, "no link"
309
+
284
310
  Args:
285
311
  all_results: The completed nested hooks, in run order.
286
312
 
287
313
  Returns:
288
- A blocking exit carrying the child's stderr when any child exits 2,
289
- else the first child ``deny`` or ``ask`` output carrying one copy
290
- of each distinct child ``additionalContext``, else a pass carrying
291
- that context, else an empty pass.
314
+ A blocking exit when any child exits 2, its stderr holding every
315
+ exit-2 child's stderr and every child ``deny`` reason; else the first
316
+ child ``deny`` output, its reason holding every child ``deny``
317
+ reason, or with no ``deny`` the first child ``ask`` output, either
318
+ carrying one copy of each distinct child ``additionalContext``; else
319
+ a pass carrying that context, else an empty pass.
292
320
  """
293
- for each_result in all_results:
294
- if each_result.returncode == BLOCKING_EXIT_CODE:
295
- return HookVerdict(BLOCKING_EXIT_CODE, "", each_result.stderr)
296
321
  all_parsed_outputs = [
297
322
  _parsed_hook_output(each_result.stdout)
298
323
  for each_result in all_results
299
324
  if each_result.returncode == 0
300
325
  ]
301
326
  all_parsed_outputs = [each_output for each_output in all_parsed_outputs if each_output is not None]
327
+ all_deny_reasons = [_deny_reason(each_output) for each_output in all_parsed_outputs]
328
+ all_blocking_stderr = [
329
+ each_result.stderr.strip()
330
+ for each_result in all_results
331
+ if each_result.returncode == BLOCKING_EXIT_CODE
332
+ ]
333
+ if all_blocking_stderr:
334
+ return HookVerdict(
335
+ BLOCKING_EXIT_CODE, "", _joined_reasons([*all_blocking_stderr, *all_deny_reasons])
336
+ )
302
337
  all_context_sections = _non_empty_sections(
303
338
  _json_additional_context(each_parsed_output) for each_parsed_output in all_parsed_outputs
304
339
  )
305
340
  all_distinct_context_sections = list(dict.fromkeys(all_context_sections))
306
341
  joined_context = CONTEXT_SECTION_SEPARATOR.join(all_distinct_context_sections)
307
- for each_parsed_output in all_parsed_outputs:
308
- if _permission_decision(each_parsed_output) in ALL_FORWARDED_PERMISSION_DECISIONS:
342
+ for each_decision in ALL_FORWARDED_PERMISSION_DECISIONS:
343
+ for each_parsed_output in all_parsed_outputs:
344
+ if _permission_decision(each_parsed_output) != each_decision:
345
+ continue
309
346
  decision_output = dict(each_parsed_output)
347
+ decision_specific_output = dict(decision_output[HOOK_SPECIFIC_OUTPUT_KEY])
310
348
  if joined_context:
311
- decision_specific_output = dict(decision_output[HOOK_SPECIFIC_OUTPUT_KEY])
312
349
  decision_specific_output[ADDITIONAL_CONTEXT_KEY] = joined_context
313
- decision_output[HOOK_SPECIFIC_OUTPUT_KEY] = decision_specific_output
350
+ joined_deny_reasons = _joined_reasons(all_deny_reasons)
351
+ if each_decision == DENY_PERMISSION_DECISION and joined_deny_reasons:
352
+ decision_specific_output[PERMISSION_DECISION_REASON_KEY] = joined_deny_reasons
353
+ decision_output[HOOK_SPECIFIC_OUTPUT_KEY] = decision_specific_output
314
354
  return HookVerdict(0, json.dumps(decision_output), "")
315
355
  if not joined_context:
316
356
  return HookVerdict(0, "", "")
@@ -189,6 +189,87 @@ def test_should_pass_a_child_deny_decision_through(tmp_path: pathlib.Path) -> No
189
189
  assert json.loads(completed_process.stdout) == deny_output
190
190
 
191
191
 
192
+ def _deny_output(reason: str) -> dict[str, object]:
193
+ return {
194
+ "hookSpecificOutput": {
195
+ "hookEventName": "PreToolUse",
196
+ "permissionDecision": "deny",
197
+ "permissionDecisionReason": reason,
198
+ }
199
+ }
200
+
201
+
202
+ def test_should_carry_every_child_deny_reason_in_one_refusal(
203
+ tmp_path: pathlib.Path,
204
+ ) -> None:
205
+ _make_checkout(
206
+ tmp_path,
207
+ "alpha",
208
+ {
209
+ "PreToolUse": [
210
+ _command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'"),
211
+ _command_group("Bash", f"echo '{json.dumps(_deny_output('too long'))}'"),
212
+ ]
213
+ },
214
+ )
215
+ _make_checkout(
216
+ tmp_path,
217
+ "beta",
218
+ {"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
219
+ )
220
+
221
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
222
+
223
+ assert completed_process.returncode == 0
224
+ assert json.loads(completed_process.stdout) == _deny_output("no link\n\ntoo long")
225
+
226
+
227
+ def test_should_carry_child_deny_reasons_into_a_blocking_exit(
228
+ tmp_path: pathlib.Path,
229
+ ) -> None:
230
+ _make_checkout(
231
+ tmp_path,
232
+ "alpha",
233
+ {"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
234
+ )
235
+ _make_checkout(
236
+ tmp_path,
237
+ "beta",
238
+ {"PreToolUse": [_command_group("Bash", "echo no tests ran >&2; exit 2")]},
239
+ )
240
+
241
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
242
+
243
+ assert completed_process.returncode == BLOCKING_EXIT_CODE
244
+ assert completed_process.stderr == "no tests ran\n\nno link"
245
+
246
+
247
+ def test_should_forward_a_later_child_deny_ahead_of_an_earlier_ask(
248
+ tmp_path: pathlib.Path,
249
+ ) -> None:
250
+ ask_output = {
251
+ "hookSpecificOutput": {
252
+ "hookEventName": "PreToolUse",
253
+ "permissionDecision": "ask",
254
+ "permissionDecisionReason": "publishing needs a person",
255
+ }
256
+ }
257
+ _make_checkout(
258
+ tmp_path,
259
+ "alpha",
260
+ {"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(ask_output)}'")]},
261
+ )
262
+ _make_checkout(
263
+ tmp_path,
264
+ "beta",
265
+ {"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
266
+ )
267
+
268
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
269
+
270
+ assert json.loads(completed_process.stdout) == _deny_output("no link")
271
+
272
+
192
273
  def test_should_not_forward_a_child_allow_decision(tmp_path: pathlib.Path) -> None:
193
274
  allow_output = {
194
275
  "hookSpecificOutput": {
@@ -72,6 +72,18 @@ def test_abbreviation_violation_flags_a_lowercase_abbreviation_and_a_tracker_num
72
72
  assert 'Tracker number "card 12"' in str(abbreviation_violation("See card 12.", SHIPPED_RULES))
73
73
 
74
74
 
75
+ def test_abbreviation_violation_names_every_abbreviation_and_tracker_number_on_its_own_line() -> None:
76
+ assert SHIPPED_RULES is not None
77
+ violation = str(abbreviation_violation("The UI and the API, e.g. card 12 and card 13.", SHIPPED_RULES))
78
+ all_lines = violation.splitlines()
79
+ assert len(all_lines) == 5
80
+ assert 'Abbreviation "UI"' in all_lines[0]
81
+ assert 'Abbreviation "API"' in all_lines[1]
82
+ assert 'Abbreviation "e.g."' in all_lines[2]
83
+ assert 'Tracker number "card 12"' in all_lines[3]
84
+ assert 'Tracker number "card 13"' in all_lines[4]
85
+
86
+
75
87
  def test_widget_anchor_violation_reads_only_widget_calls() -> None:
76
88
  anchor_input = {"widget_code": '<a href="https://example.com">page</a>'}
77
89
  assert widget_anchor_violation("mcp__hearthbot__post_widget", anchor_input) is not None
@@ -19,6 +19,7 @@ from pathlib import Path
19
19
  from json_file_reader import read_json_object
20
20
  from hooks_constants.visual_reply_rules_constants import (
21
21
  ABBREVIATION_MESSAGE,
22
+ VIOLATION_LINE_SEPARATOR,
22
23
  ABBREVIATIONS_RULE_KEY,
23
24
  ALL_REPLY_TOOL_NAME_SUFFIXES,
24
25
  ALL_VISUAL_TOOL_NAME_SUFFIXES,
@@ -140,19 +141,35 @@ def mode_enabled(claude_home: Path) -> bool:
140
141
 
141
142
 
142
143
  def abbreviation_violation(prose_text: str, rules: VisualReplyRules) -> str | None:
143
- """Return the deny reason for the first abbreviation in prose with links and code removed."""
144
+ """Return the deny reason naming each abbreviation and tracker number in prose with links and code removed.
145
+
146
+ Each one gets its own line, so one refusal names them all.
147
+ """
144
148
  reminder = rules.reminder(ABBREVIATIONS_RULE_KEY)
145
- for each_match in CAPITALIZED_ABBREVIATION_PATTERN.finditer(prose_text):
146
- if each_match.group(0) not in rules.all_allowed_capitalized_words:
147
- return ABBREVIATION_MESSAGE.format(abbreviation=each_match.group(0), reminder=reminder)
148
- for each_abbreviation in rules.all_lowercase_abbreviations:
149
- pattern = LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape(each_abbreviation))
150
- if re.search(pattern, prose_text, re.IGNORECASE):
151
- return ABBREVIATION_MESSAGE.format(abbreviation=each_abbreviation, reminder=reminder)
152
- tracker_match = TRACKER_NUMBER_PATTERN.search(prose_text)
153
- if tracker_match is not None:
154
- return TRACKER_NUMBER_MESSAGE.format(reference=tracker_match.group(0), reminder=reminder)
155
- return None
149
+ all_abbreviations = [
150
+ each_match.group(0)
151
+ for each_match in CAPITALIZED_ABBREVIATION_PATTERN.finditer(prose_text)
152
+ if each_match.group(0) not in rules.all_allowed_capitalized_words
153
+ ] + [
154
+ each_abbreviation
155
+ for each_abbreviation in rules.all_lowercase_abbreviations
156
+ if re.search(
157
+ LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape(each_abbreviation)),
158
+ prose_text,
159
+ re.IGNORECASE,
160
+ )
161
+ ]
162
+ all_violations = [
163
+ *(
164
+ ABBREVIATION_MESSAGE.format(abbreviation=each_abbreviation, reminder=reminder)
165
+ for each_abbreviation in dict.fromkeys(all_abbreviations)
166
+ ),
167
+ *(
168
+ TRACKER_NUMBER_MESSAGE.format(reference=each_match.group(0), reminder=reminder)
169
+ for each_match in TRACKER_NUMBER_PATTERN.finditer(prose_text)
170
+ ),
171
+ ]
172
+ return VIOLATION_LINE_SEPARATOR.join(all_violations) if all_violations else None
156
173
 
157
174
 
158
175
  def widget_anchor_violation(tool_name: str, all_tool_input: dict[str, object]) -> str | None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.106.2",
3
+ "version": "8.106.4",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,9 @@ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
61
61
  "paired-test-missing-function",
62
62
  ),
63
63
  CheckCatalogEntry("but exercises nowhere", "paired-test-omitted-function"),
64
+ CheckCatalogEntry(") - consider splitting", "file-length"),
65
+ CheckCatalogEntry(" lines (max ", "function-line-cap"),
66
+ CheckCatalogEntry(" has nesting depth ", "nesting-depth"),
64
67
  )
65
68
 
66
69
  SEVERITY_BREAKING: str = "breaking"
@@ -68,5 +71,8 @@ SEVERITY_SMELL: str = "smell"
68
71
  SEVERITY_BY_CHECK_ID: dict[str, str] = {
69
72
  "code-rules/paired-test-missing-function": SEVERITY_SMELL,
70
73
  "code-rules/paired-test-omitted-function": SEVERITY_SMELL,
74
+ "validators/file-length": SEVERITY_SMELL,
75
+ "validators/function-line-cap": SEVERITY_SMELL,
76
+ "validators/nesting-depth": SEVERITY_SMELL,
71
77
  "test-pairing": SEVERITY_SMELL,
72
78
  }
@@ -18,6 +18,7 @@ from policy_lint.config.check_catalog_constants import (
18
18
  from policy_lint.model import Diagnostic, Severity
19
19
 
20
20
  _BUNDLING_RULE_ID = "code-rules"
21
+ _VALIDATORS_RULE_ID = "validators"
21
22
  _HOOKS_DIRECTORY = Path(__file__).resolve().parents[2] / "hooks"
22
23
  _TEST_FILE_PREFIX = "test_"
23
24
  _PYTHON_GLOB = "*.py"
@@ -82,6 +83,9 @@ ALL_MESSAGE_SAMPLES: tuple[tuple[str, str], ...] = (
82
83
  " already covers another public function in that module",
83
84
  "paired-test-omitted-function",
84
85
  ),
86
+ ("src/app.py:1: File is 412 lines (max 400) - consider splitting", "file-length"),
87
+ ("src/app.py:4: Function 'run' is 37 lines (max 30)", "function-line-cap"),
88
+ ("src/app.py:9: Function 'run' has nesting depth 3 (max 2)", "nesting-depth"),
85
89
  )
86
90
 
87
91
 
@@ -169,6 +173,23 @@ def test_a_paired_test_check_reports_as_a_warning(check_name: str) -> None:
169
173
  assert severity_for_check_id(f"{_BUNDLING_RULE_ID}/{check_name}") is Severity.WARNING
170
174
 
171
175
 
176
+ @pytest.mark.parametrize(
177
+ ("sample_message", "check_name"),
178
+ tuple(
179
+ each_sample
180
+ for each_sample in ALL_MESSAGE_SAMPLES
181
+ if each_sample[1] in ("file-length", "function-line-cap", "nesting-depth")
182
+ ),
183
+ )
184
+ def test_a_validator_smell_reports_as_a_warning(
185
+ sample_message: str, check_name: str
186
+ ) -> None:
187
+ check_id = check_id_for_message(_VALIDATORS_RULE_ID, sample_message)
188
+
189
+ assert check_id == f"{_VALIDATORS_RULE_ID}/{check_name}"
190
+ assert severity_for_check_id(check_id) is Severity.WARNING
191
+
192
+
172
193
  def test_an_unclassified_check_reports_as_an_error() -> None:
173
194
  unclassified_check_id = f"{_BUNDLING_RULE_ID}/{UNCLASSIFIED_CHECK_NAME}"
174
195