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.
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/reply_length_gate.py +229 -106
- package/hooks/blocking/test_reply_length_gate.py +77 -0
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/features/lifecycle-cleanup.md +2 -2
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/hook_specific_output_keys.py +2 -0
- package/hooks/hooks_constants/nested_project_hooks_constants.py +3 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -0
- package/hooks/hooks_constants/test_hook_specific_output_keys.py +1 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +1 -0
- package/hooks/lifecycle/nested_project_hooks.py +52 -12
- package/hooks/lifecycle/test_nested_project_hooks.py +81 -0
- package/hooks/test_visual_reply_rules.py +12 -0
- package/hooks/visual_reply_rules.py +29 -12
- package/package.json +1 -1
- package/scripts/policy_lint/config/check_catalog_constants.py +6 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +21 -0
package/docs/CODE_RULES.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
374
|
+
"""Return the deny reason for each banned word in the prose, or None."""
|
|
307
375
|
prose_text = countable_text(reply_text)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
return
|
|
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
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
446
|
-
length_violation(reply_text)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
if
|
|
473
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
577
|
-
violation
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
|
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>]:
|
|
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\": \"
|
|
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
|
-
|
|
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
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
@@ -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
|
|