claude-dev-env 8.106.1 → 8.106.3

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.
@@ -174,11 +174,6 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
174
174
  export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
175
175
  export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
176
176
  export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
177
- export const CODEX_SUBAGENT_PROMPT_MATCHER = 'Agent|Task';
178
- export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
179
- CODEX_NATIVE_ROUTING_MATCHER,
180
- CODEX_SUBAGENT_PROMPT_MATCHER,
181
- ]);
182
177
  export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
183
178
  'open_tasks_stop_gate.py',
184
179
  'owned_pr_merge_stop_gate.py',
@@ -130,11 +130,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
130
130
  assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
131
131
  assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
132
132
  assert.match(firstRoutingGroups[0].hooks[2].command, /spawn_oversight_hook\.py/);
133
- const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
134
- group => group.matcher === 'Agent|Task',
133
+ assert.deepEqual(
134
+ firstCodexHooks.hooks.PreToolUse.map(group => group.matcher),
135
+ ['multi_agent_v1__spawn_agent'],
135
136
  );
136
- assert.equal(firstSpawnPromptGroups.length, 1);
137
- assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
138
137
  assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'Stop', 'SessionStart']);
139
138
  const allCodexPostToolUseCommands = firstCodexHooks.hooks.PostToolUse.flatMap(
140
139
  group => group.hooks.map(hook => hook.command),
@@ -276,6 +275,7 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
276
275
  const allLegacyRegistrations = [
277
276
  ['PreToolUse', 'Edit', '.claude', 'blocking/pre_tool_use_dispatcher.py'],
278
277
  ['PreToolUse', 'Bash', '.agents', 'blocking/bash_pre_tool_use_dispatcher.py'],
278
+ ['PreToolUse', 'Agent|Task', 'custom-codex', 'session/skill_loaded_reminder.py'],
279
279
  ['SessionStart', '*', 'custom-codex', 'session/issue_tracker_session_starter.py'],
280
280
  ['Stop', '*', '.claude', 'session/skill_loaded_reminder.py'],
281
281
  ['SessionEnd', '*', 'custom-codex', 'lifecycle/session_end_cleanup.py'],
@@ -311,8 +311,13 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
311
311
 
312
312
  const migratedHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
313
313
  const allSpawnGroups = migratedHooks.hooks.PreToolUse.filter(eachGroup =>
314
- ['Agent|Task', 'multi_agent_v1__spawn_agent'].includes(eachGroup.matcher));
315
- assert.equal(allSpawnGroups.length, 2);
314
+ eachGroup.matcher === 'multi_agent_v1__spawn_agent');
315
+ assert.equal(allSpawnGroups.length, 1);
316
+ assert.equal(
317
+ migratedHooks.hooks.PreToolUse.flatMap(eachGroup => eachGroup.hooks)
318
+ .some(eachHook => String(eachHook.command).includes('skill_loaded_reminder.py')),
319
+ false,
320
+ );
316
321
  migratedHooks.hooks.PreToolUse = migratedHooks.hooks.PreToolUse.filter(eachGroup => !allSpawnGroups.includes(eachGroup));
317
322
  const allPackagePromptCommands = (migratedHooks.hooks.UserPromptSubmit ?? [])
318
323
  .flatMap(eachGroup => eachGroup.hooks)
package/bin/install.mjs CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  SUBAGENT_MODEL_POLICY_FILE_NAME,
51
51
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
52
52
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
53
- ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
53
+ CODEX_NATIVE_ROUTING_MATCHER,
54
54
  ALL_CODEX_NATIVE_SESSION_START_HOOK_SCRIPT_NAMES,
55
55
  ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
56
56
  ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
@@ -1315,6 +1315,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1315
1315
  'blocking/session_title_stop_gate.py',
1316
1316
  'session/issue_tracker_session_starter.py',
1317
1317
  'session/deepen_first_prompt.py',
1318
+ 'workflow/investigation_tracker_reset.py',
1318
1319
  ]);
1319
1320
 
1320
1321
  export const MOVED_HOOK_RELATIVE_PATHS = new Set([
@@ -2283,7 +2284,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
2283
2284
  ...hooksConfig,
2284
2285
  hooks: {
2285
2286
  PreToolUse: Array.isArray(preToolUseGroups)
2286
- ? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
2287
+ ? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
2287
2288
  : [],
2288
2289
  UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
2289
2290
  PostToolUse: selectCodexNativeGroupsByScriptName(
@@ -1245,6 +1245,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
1245
1245
  'blocking/session_title_stop_gate.py',
1246
1246
  'session/issue_tracker_session_starter.py',
1247
1247
  'session/deepen_first_prompt.py',
1248
+ 'workflow/investigation_tracker_reset.py',
1248
1249
  ]);
1249
1250
  const shippedHooks = JSON.parse(
1250
1251
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
@@ -26,8 +26,8 @@ Run `codex-compat bridge --surface <name> --payload '<json-object>'`. The bridge
26
26
 
27
27
  The package installer seeds `subagent-model-policy.json` in the agents-home
28
28
  `rules/` directory when the file is absent. It registers one `PreToolUse` group
29
- for `multi_agent_v1__spawn_agent` and one for `Agent|Task` in `$CODEX_HOME/hooks.json`.
30
- Before adding these groups, the installer removes obsolete package-owned hook
29
+ for `multi_agent_v1__spawn_agent` in `$CODEX_HOME/hooks.json`.
30
+ Before adding this group, the installer removes obsolete package-owned hook
31
31
  registrations from every event. Custom hook entries and Codex's enabled or
32
32
  disabled hook settings stay unchanged. Claude-only hook groups do not enter the
33
33
  Codex file.
@@ -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]
@@ -28,6 +28,6 @@ Each family page starts with a title and an agent-facing behavior summary. Its f
28
28
  - [Conduct gates](./conduct-gates.md) covers chat replies, edit markers, step notes, checked claims, and the pull request lifecycle skill.
29
29
  - [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
30
30
  - [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
31
- - [Observability](./observability.md) covers instruction loads, edited files, and investigation resets.
31
+ - [Observability](./observability.md) covers instruction loads and edited files.
32
32
  - [Read gates](./read-gates.md) covers the image size cap on Read.
33
33
  - [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.