claude-dev-env 2.7.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CLAUDE.md +7 -1
  2. package/_shared/CLAUDE.md +1 -0
  3. package/_shared/advisor/advisor-protocol.md +19 -9
  4. package/_shared/pr-loop/audit-contract.md +4 -4
  5. package/_shared/pr-loop/precatch-rubric.md +2 -2
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/clean-coder.md +9 -19
  13. package/agents/code-quality-agent.md +6 -5
  14. package/agents/deep-research.md +7 -24
  15. package/agents/docs-agent.md +1 -27
  16. package/agents/issue-tracker.md +1 -7
  17. package/agents/skill-writer-agent.md +1 -2
  18. package/agents/test_agent_frontmatter.py +335 -12
  19. package/docs/CODE_RULES.md +4 -2
  20. package/docs/references/CLAUDE.md +2 -2
  21. package/docs/references/advisor-tool.md +44 -6
  22. package/docs/references/team-advisor-skill.md +14 -8
  23. package/hooks/blocking/CLAUDE.md +2 -0
  24. package/hooks/blocking/fable_spawn_gate.py +187 -0
  25. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  26. package/hooks/blocking/plain_language_blocker.py +287 -15
  27. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  28. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  29. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  30. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  31. package/hooks/git-hooks/CLAUDE.md +2 -2
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  33. package/hooks/git-hooks/pre_push.py +343 -54
  34. package/hooks/git-hooks/test_pre_push.py +852 -6
  35. package/hooks/hooks.json +9 -19
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  39. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  40. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  41. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  43. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  46. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  47. package/output-styles/CLAUDE.md +17 -0
  48. package/output-styles/caveman-agent.md +37 -0
  49. package/package.json +2 -1
  50. package/rules/CLAUDE.md +1 -0
  51. package/rules/ask-user-question-required.md +26 -0
  52. package/rules/claims-as-quotes.md +65 -0
  53. package/rules/code-standards.md +33 -7
  54. package/rules/eli11-replies.md +1 -1
  55. package/scripts/CLAUDE.md +3 -3
  56. package/scripts/_code_review_test_support.py +6 -0
  57. package/scripts/check.ps1 +18 -5
  58. package/scripts/claude_chain_runner.py +203 -31
  59. package/scripts/codec_forwarding_test_support.py +2 -0
  60. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  61. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  62. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  63. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  64. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +132 -24
  65. package/scripts/grok_headless_runner.py +16 -83
  66. package/scripts/grok_patch_artifacts.py +123 -0
  67. package/scripts/grok_run_ledger.py +318 -0
  68. package/scripts/spawn_grok_batch.py +591 -10
  69. package/scripts/test_claude_chain_runner.py +358 -0
  70. package/scripts/test_grok_headless_runner.py +18 -63
  71. package/scripts/test_grok_patch_artifacts.py +82 -0
  72. package/scripts/test_grok_run_ledger.py +116 -0
  73. package/scripts/test_invoke_code_review.py +298 -0
  74. package/scripts/test_resolve_worker_spawn.py +6 -0
  75. package/scripts/test_spawn_grok_batch.py +396 -0
  76. package/scripts/tests/CLAUDE.md +1 -0
  77. package/scripts/tests/test_grok_worker_constants.py +59 -0
  78. package/skills/CLAUDE.md +4 -2
  79. package/skills/_shared/CLAUDE.md +37 -4
  80. package/skills/_shared/advisor/CLAUDE.md +9 -0
  81. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  82. package/skills/_shared/advisor/scripts/README.md +9 -0
  83. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  84. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  85. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  86. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  87. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  88. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  89. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  90. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  91. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  92. package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
  93. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  94. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  95. package/skills/_shared/pr-loop/state-schema.md +5 -0
  96. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  98. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  99. package/skills/autoconverge/workflow/converge.mjs +66 -33
  100. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  101. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  102. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  103. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  104. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  105. package/skills/e-code-review/SKILL.md +15 -9
  106. package/skills/e-code-review/reference/fix.md +29 -7
  107. package/skills/e-code-review/reference/loop.md +230 -14
  108. package/skills/e-code-review/reference/low.md +33 -15
  109. package/skills/e-code-review/reference/medium.md +55 -21
  110. package/skills/e-code-review/reference/runner-selection.md +40 -0
  111. package/skills/e-code-review/reference/xhigh.md +30 -12
  112. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  113. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  114. package/skills/e-code-review/scripts/grok_code_review.py +221 -0
  115. package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
  116. package/skills/fresh-branch/CLAUDE.md +5 -5
  117. package/skills/fresh-branch/SKILL.md +14 -6
  118. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  119. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  120. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  121. package/skills/grok-spawn/SKILL.md +10 -0
  122. package/skills/orchestrator/SKILL.md +9 -1
  123. package/skills/orchestrator-refresh/SKILL.md +5 -1
  124. package/skills/task-build/reference/tool-routing.md +3 -0
  125. package/skills/team-advisor/SKILL.md +23 -41
  126. package/system-prompts/software-engineer.xml +6 -3
  127. package/skills/test_markdown_link_integrity.py +0 -107
@@ -1,13 +1,24 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse hook that blocks heavy words in AskUserQuestion prose and .md writes.
3
-
4
- Reaches for the everyday word over the formal one: `use` over `utilize`,
5
- `start` over `initiate`, `enough` over `sufficient`. Two surfaces are guarded --
6
- AskUserQuestion (its question and option prose) and Write/Edit/MultiEdit targeting a .md
7
- file. Code fences, inline code, blockquotes, URLs, and file paths are stripped
8
- before matching so exact identifiers and paths are never flagged.
9
-
10
- See the plain-language rule for the full guidance this hook enforces.
2
+ """PreToolUse hook guarding AskUserQuestion prose and .md writes.
3
+
4
+ Two checks run. The word check reaches for the everyday word over the formal
5
+ one: `use` over `utilize`, `start` over `initiate`, `enough` over `sufficient`.
6
+ It reads both guarded surfaces -- AskUserQuestion (its question and option
7
+ prose) and Write/Edit/MultiEdit targeting a .md file -- with code fences,
8
+ inline code, blockquotes, URLs, and file paths stripped first, so exact
9
+ identifiers and paths are never flagged.
10
+
11
+ The lean-block check reads the AskUserQuestion surface alone. AskUserQuestion
12
+ renders as one plain text block, so the question text and each option
13
+ description stay short and unformatted: a fenced block, a heading, a table row,
14
+ a bullet or numbered list marker, a second paragraph, or prose past the
15
+ sentence and word caps all belong in chat text before the call. Structure is
16
+ read at block level on the raw text, with line endings folded to one spelling
17
+ first; an inline code span naming a path, a flag, or a command weighs one word
18
+ against the length caps.
19
+
20
+ See the plain-language and ask-user-question-required rules for the full
21
+ guidance this hook enforces.
11
22
  """
12
23
 
13
24
  import json
@@ -22,20 +33,36 @@ if _hooks_dir not in sys.path:
22
33
  from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
23
34
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
35
  from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
36
+ ALL_CHAT_DETAIL_MARKERS,
37
+ ALL_LINE_ENDING_REPLACEMENTS,
25
38
  ALL_SOFTWARE_TERMS,
26
39
  ALL_TERM_PATTERNS,
27
40
  ALL_WRITE_EDIT_TOOL_NAMES,
28
41
  ASK_USER_QUESTION_TOOL_NAME,
29
42
  BLOCKQUOTE_LINE_PATTERN,
43
+ COUNTABLE_WORD_PATTERN,
30
44
  DOT_CLAUDE_DIRECTORY_NAME,
31
45
  FENCED_CODE_BLOCK_PATTERN,
32
46
  FILE_PATH_PATTERN,
33
47
  INLINE_CODE_PATTERN,
48
+ INLINE_CODE_PLACEHOLDER,
49
+ INLINE_CODE_SPAN_PATTERN,
50
+ LEAN_QUESTION_BLOCK_GUIDANCE,
51
+ LEAN_QUESTION_BLOCK_PREFIX,
52
+ LEAN_QUESTION_VIOLATION_SEPARATOR,
34
53
  MARKDOWN_EXTENSION,
54
+ MAXIMUM_OPTION_DESCRIPTION_SENTENCE_COUNT,
55
+ MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT,
56
+ MAXIMUM_QUESTION_SENTENCE_COUNT,
57
+ MAXIMUM_QUESTION_WORD_COUNT,
58
+ OPTION_DESCRIPTION_SURFACE_NAME,
35
59
  PROJECT_ALLOWLIST_FILENAME,
36
60
  PROJECT_ROOT_WALK_LIMIT,
61
+ QUESTION_SURFACE_NAME,
37
62
  REPOSITORY_MARKER_NAME,
63
+ SENTENCE_BOUNDARY_PATTERN,
38
64
  URL_PATTERN,
65
+ USER_FACING_LEAN_QUESTION_NOTICE,
39
66
  USER_FACING_PLAIN_LANGUAGE_NOTICE,
40
67
  )
41
68
 
@@ -219,6 +246,229 @@ def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
219
246
  )
220
247
 
221
248
 
249
+ def _normalize_line_endings(prose_text: str) -> str:
250
+ """Return the prose with every line ending spelled as a bare line feed.
251
+
252
+ ::
253
+
254
+ in: "Which gate?\\r\\n\\r\\nThe write gate reads it."
255
+ out: "Which gate?\\n\\nThe write gate reads it."
256
+
257
+ The structure markers read line boundaries, so a payload carrying carriage
258
+ returns is normalized once here rather than at each pattern.
259
+
260
+ Args:
261
+ prose_text: One question text or one option description.
262
+
263
+ Returns:
264
+ The prose with carriage returns folded into line feeds.
265
+ """
266
+ normalized_text = prose_text
267
+ for each_line_ending, each_replacement in ALL_LINE_ENDING_REPLACEMENTS:
268
+ normalized_text = normalized_text.replace(each_line_ending, each_replacement)
269
+ return normalized_text
270
+
271
+
272
+ def _mask_inline_code(prose_text: str) -> str:
273
+ """Return the prose with each inline code span collapsed to a single word.
274
+
275
+ ::
276
+
277
+ in: "Does `git diff --cached` list it?"
278
+ out: "Does code list it?"
279
+
280
+ A span names a path, a flag, or a command the reader needs verbatim, so it
281
+ weighs one word against the length caps. Only the length counts read the
282
+ masked prose: the structure markers read the raw text, so a one-line closed
283
+ fence still reads as a fence rather than as a span.
284
+
285
+ Args:
286
+ prose_text: One question text or one option description.
287
+
288
+ Returns:
289
+ The prose with every single-line inline code span replaced.
290
+ """
291
+ return INLINE_CODE_SPAN_PATTERN.sub(INLINE_CODE_PLACEHOLDER, prose_text)
292
+
293
+
294
+ def _count_prose_sentences(prose_text: str) -> int:
295
+ """Return how many sentences one piece of question-block prose carries.
296
+
297
+ ::
298
+
299
+ in: "Which gate runs first? Both read the same lines."
300
+ out: 2
301
+
302
+ A sentence closes on `.`, `!`, or `?` followed by a capitalized word or the
303
+ end of the text, so an abbreviation mid-sentence closes nothing.
304
+
305
+ Args:
306
+ prose_text: One question text or one option description.
307
+
308
+ Returns:
309
+ The count of sentence closings the prose carries.
310
+ """
311
+ return len(SENTENCE_BOUNDARY_PATTERN.findall(prose_text))
312
+
313
+
314
+ def _count_prose_words(prose_text: str) -> int:
315
+ """Return how many reader-visible words one piece of prose carries.
316
+
317
+ Args:
318
+ prose_text: One question text or one option description.
319
+
320
+ Returns:
321
+ The count of whitespace-separated tokens carrying a letter or digit.
322
+ """
323
+ return len(COUNTABLE_WORD_PATTERN.findall(prose_text))
324
+
325
+
326
+ def _find_chat_detail_markers(prose_text: str) -> list[str]:
327
+ """Return the name of each chat-detail marker the prose carries.
328
+
329
+ ::
330
+
331
+ ok: "Which gate should run first?" -> []
332
+ flag: "Which gate?\\n- write\\n- commit" -> ["a bullet or numbered
333
+ list marker"]
334
+
335
+ Args:
336
+ prose_text: One question text or one option description.
337
+
338
+ Returns:
339
+ One marker name per marker found, in the order the markers are tried.
340
+ """
341
+ return [
342
+ each_marker_name
343
+ for each_pattern, each_marker_name in ALL_CHAT_DETAIL_MARKERS
344
+ if each_pattern.search(prose_text)
345
+ ]
346
+
347
+
348
+ def _find_lean_block_violations(
349
+ prose_text: str,
350
+ surface_name: str,
351
+ maximum_sentence_count: int,
352
+ maximum_word_count: int,
353
+ ) -> list[str]:
354
+ """Return every lean-block rule one piece of question-block prose breaks.
355
+
356
+ Args:
357
+ prose_text: One question text or one option description.
358
+ surface_name: How the violation text names this piece of prose.
359
+ maximum_sentence_count: The sentence cap this piece answers to.
360
+ maximum_word_count: The word cap this piece answers to.
361
+
362
+ Returns:
363
+ One violation text per broken rule, empty when the prose is lean.
364
+
365
+ The structure markers read the line-ending-normalized prose; the sentence
366
+ and word counts read that prose with its inline code spans masked.
367
+ """
368
+ normalized_text = _normalize_line_endings(prose_text)
369
+ masked_text = _mask_inline_code(normalized_text)
370
+ all_violations = [
371
+ f"{surface_name} carries {each_marker_name}"
372
+ for each_marker_name in _find_chat_detail_markers(normalized_text)
373
+ ]
374
+ sentence_count = _count_prose_sentences(masked_text)
375
+ if sentence_count > maximum_sentence_count:
376
+ all_violations.append(
377
+ f"{surface_name} runs {sentence_count} sentences, over the "
378
+ f"{maximum_sentence_count}-sentence cap"
379
+ )
380
+ word_count = _count_prose_words(masked_text)
381
+ if word_count > maximum_word_count:
382
+ all_violations.append(
383
+ f"{surface_name} runs {word_count} words, over the "
384
+ f"{maximum_word_count}-word cap"
385
+ )
386
+ return all_violations
387
+
388
+
389
+ def _violations_for_one_question(question_by_key: dict) -> list[str]:
390
+ """Return the lean-block violations one question entry carries.
391
+
392
+ Args:
393
+ question_by_key: One entry of the AskUserQuestion questions list.
394
+
395
+ Returns:
396
+ One violation text per broken rule across the question text and every
397
+ option description the entry carries.
398
+ """
399
+ all_violations: list[str] = []
400
+ question_text = question_by_key.get("question", "")
401
+ if isinstance(question_text, str):
402
+ all_violations.extend(
403
+ _find_lean_block_violations(
404
+ question_text,
405
+ QUESTION_SURFACE_NAME,
406
+ MAXIMUM_QUESTION_SENTENCE_COUNT,
407
+ MAXIMUM_QUESTION_WORD_COUNT,
408
+ )
409
+ )
410
+ all_options = question_by_key.get("options", [])
411
+ if not isinstance(all_options, list):
412
+ return all_violations
413
+ for each_option in all_options:
414
+ if not isinstance(each_option, dict):
415
+ continue
416
+ option_description = each_option.get("description", "")
417
+ if not isinstance(option_description, str):
418
+ continue
419
+ all_violations.extend(
420
+ _find_lean_block_violations(
421
+ option_description,
422
+ OPTION_DESCRIPTION_SURFACE_NAME,
423
+ MAXIMUM_OPTION_DESCRIPTION_SENTENCE_COUNT,
424
+ MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT,
425
+ )
426
+ )
427
+ return all_violations
428
+
429
+
430
+ def find_question_block_violations(tool_input: dict) -> list[str]:
431
+ """Return every lean-block violation an AskUserQuestion payload carries.
432
+
433
+ ::
434
+
435
+ ok: "Which gate should run first?" + "Runs on every write." -> []
436
+ flag: a question with a three-bullet plan under it
437
+ -> ["the question carries a bullet or numbered list marker"]
438
+
439
+ Each violation text appears once, so two options breaking the same cap read
440
+ as one line.
441
+
442
+ Args:
443
+ tool_input: The AskUserQuestion tool input carrying the questions list.
444
+
445
+ Returns:
446
+ One violation text per broken rule, in first-seen order, empty when the
447
+ whole question block is lean.
448
+ """
449
+ all_questions = tool_input.get("questions", [])
450
+ if not isinstance(all_questions, list):
451
+ return []
452
+ all_violations: list[str] = []
453
+ for each_question in all_questions:
454
+ if isinstance(each_question, dict):
455
+ all_violations.extend(_violations_for_one_question(each_question))
456
+ return list(dict.fromkeys(all_violations))
457
+
458
+
459
+ def build_lean_block_reason(all_violations: list[str]) -> str:
460
+ """Return a deny reason naming each chat detail and where the detail belongs.
461
+
462
+ Args:
463
+ all_violations: The violation texts the question block earned.
464
+
465
+ Returns:
466
+ The full deny reason the model rewrites its question block against.
467
+ """
468
+ violation_list = LEAN_QUESTION_VIOLATION_SEPARATOR.join(all_violations)
469
+ return f"{LEAN_QUESTION_BLOCK_PREFIX}{violation_list}. {LEAN_QUESTION_BLOCK_GUIDANCE}"
470
+
471
+
222
472
  def _collect_ask_user_question_prose(tool_input: dict) -> str:
223
473
  all_questions = tool_input.get("questions", [])
224
474
  if not isinstance(all_questions, list):
@@ -273,6 +523,21 @@ def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
273
523
  return ""
274
524
 
275
525
 
526
+ def _notice_for_deny_reason(deny_reason: str) -> str:
527
+ """Return the short notice matching the check that produced a deny reason.
528
+
529
+ Args:
530
+ deny_reason: The permissionDecisionReason text for the denial.
531
+
532
+ Returns:
533
+ The lean-block notice for a question-block denial, the word-swap notice
534
+ otherwise.
535
+ """
536
+ if deny_reason.startswith(LEAN_QUESTION_BLOCK_PREFIX):
537
+ return USER_FACING_LEAN_QUESTION_NOTICE
538
+ return USER_FACING_PLAIN_LANGUAGE_NOTICE
539
+
540
+
276
541
  def build_deny_payload(deny_reason: str) -> dict[str, object]:
277
542
  """Build the full deny payload the hook writes for a deny-reason string.
278
543
 
@@ -297,7 +562,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
297
562
  "permissionDecision": "deny",
298
563
  "permissionDecisionReason": deny_reason,
299
564
  },
300
- "systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
565
+ "systemMessage": _notice_for_deny_reason(deny_reason),
301
566
  "suppressOutput": True,
302
567
  }
303
568
 
@@ -308,17 +573,19 @@ def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
308
573
 
309
574
 
310
575
  def evaluate(payload_by_key: dict[str, object]) -> str | None:
311
- """Decide whether a payload's prose carries heavy words to block.
576
+ """Decide whether a payload carries a question block or heavy words to block.
312
577
 
313
- Collects the prose for the payload's tool, scans it for banned terms, and
314
- returns the deny-reason text when any heavy word is found, or None to allow.
578
+ An AskUserQuestion payload meets the lean-block check first, so a question
579
+ text or an option description carrying chat detail returns a LEAN_QUESTION
580
+ deny reason. Every guarded payload then meets the word scan, which returns a
581
+ PLAIN_LANGUAGE deny reason for the first heavy word it finds.
315
582
 
316
583
  Args:
317
584
  payload_by_key: The PreToolUse payload with tool_name and tool_input.
318
585
 
319
586
  Returns:
320
- The permissionDecisionReason text when the prose is denied, or None when
321
- the prose is allowed.
587
+ The permissionDecisionReason text when the payload is denied, or None
588
+ when it is allowed.
322
589
  """
323
590
  raw_tool_name = payload_by_key.get("tool_name", "")
324
591
  raw_tool_input = payload_by_key.get("tool_input", {})
@@ -332,6 +599,11 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
332
599
  ):
333
600
  return None
334
601
 
602
+ if raw_tool_name == ASK_USER_QUESTION_TOOL_NAME:
603
+ all_block_violations = find_question_block_violations(raw_tool_input)
604
+ if all_block_violations:
605
+ return build_lean_block_reason(all_block_violations)
606
+
335
607
  prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
336
608
  if not prose_text:
337
609
  return None