diff --git a/engine/hooks/diu-stop/claude_stop_check.py b/engine/hooks/diu-stop/claude_stop_check.py index bb450ec0..0e1d2d95 100755 --- a/engine/hooks/diu-stop/claude_stop_check.py +++ b/engine/hooks/diu-stop/claude_stop_check.py @@ -42,6 +42,8 @@ checks cannot loop that way, because a well-formed {{CAT-UNVERIFIED: ... -- cannot verify: }} always passes and every block message names it. There is always a legal move that ends the turn. +Every block names every flagged sentence, so one rewrite that fixes them +all gets through. """ import json import os @@ -56,6 +58,11 @@ import markers # noqa: E402 +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_markers")) + +import markers # noqa: E402 + # Phrases banned outright (from this user's global CLAUDE.md evidence # rules) -- rarely legitimate even mid-sentence, so no opener restriction. BANNED_PHRASES_UNCONDITIONAL = [ @@ -121,6 +128,11 @@ re.IGNORECASE | re.DOTALL, ) +# Where one sentence ends and the next begins, for quoting a flagged +# sentence back in the block message. +SENTENCE_END_RE = re.compile(r"(?<=[.!?])\s+") +SENTENCE_LIMIT = 120 + def _opening_word(message): stripped = message.lstrip() @@ -143,9 +155,42 @@ def find_marker_problems(message): return problems -def find_unverified_claim(message): - """Return the offending phrase if a paragraph makes an unverified-shaped - claim with no evidence marker in that same paragraph. +def _sentence_at(para, pos): + """The sentence of `para` that contains offset `pos`, on one line and + cut to SENTENCE_LIMIT characters so a block quoting it stays short.""" + start, end = 0, len(para) + for boundary in SENTENCE_END_RE.finditer(para): + if boundary.end() <= pos: + start = boundary.end() + elif boundary.start() >= pos: + end = boundary.start() + break + sentence = " ".join(para[start:end].split()) + if len(sentence) > SENTENCE_LIMIT: + sentence = sentence[:SENTENCE_LIMIT - 3] + "..." + return sentence + + +def _paragraph_claim(para): + """(trigger phrase, offset) for the first claim in `para`, or None.""" + lowered = para.lower() + for phrase in BANNED_PHRASES_UNCONDITIONAL: + if phrase in lowered: + return phrase, lowered.index(phrase) + opener = _opening_word(para) + if opener in BANNED_OPENERS: + return opener, 0 + for pattern in (CAUSAL_CLOSER_RE, HEDGE_CLAIM_RE): + match = pattern.search(para) + if match: + return match.group(0), match.start() + return None + + +def find_unverified_claims(message): + """Return one (trigger phrase, sentence) pair for every paragraph that + makes an unverified-shaped claim with no evidence marker in that same + paragraph, in message order. A well-formed `{{CAT-UNVERIFIED}}` tag silences the paragraph it sits in, exactly like a fence -- not a later/earlier claim. Inline code @@ -153,6 +198,7 @@ def find_unverified_claim(message): message carries a fenced block of output. This is a blunt proxy, not a truth check.""" fenced_output = any(OUTPUT_SHAPE_RE.search(body) for body in FENCED_BODY_RE.findall(message)) + claims = [] for para in re.split(r"\n\s*\n", message): para = FENCED_BODY_RE.sub("", para) if not para.strip(): @@ -166,20 +212,18 @@ def find_unverified_claim(message): inline = INLINE_CODE_RE.findall(para) if inline and (fenced_output or any(OUTPUT_SHAPE_RE.search(code) for code in inline)): continue - lowered = para.lower() - for phrase in BANNED_PHRASES_UNCONDITIONAL: - if phrase in lowered: - return phrase - opener = _opening_word(para) - if opener in BANNED_OPENERS: - return opener - causal = CAUSAL_CLOSER_RE.search(para) - if causal: - return causal.group(0) - hedge = HEDGE_CLAIM_RE.search(para) - if hedge: - return hedge.group(0) - return None + hit = _paragraph_claim(para) + if hit: + phrase, pos = hit + claims.append((phrase, _sentence_at(para, pos))) + return claims + + +def find_unverified_claim(message): + """Return the first offending phrase find_unverified_claims reports, or + None.""" + claims = find_unverified_claims(message) + return claims[0][0] if claims else None def main(): @@ -198,24 +242,31 @@ def main(): word_count = counted_words(message) over_limit = word_count > WORD_LIMIT and not retry - claim = find_unverified_claim(message) + claims = find_unverified_claims(message) marker_problems = find_marker_problems(message) - if not over_limit and not claim and not marker_problems and not plain_words_note: + if not over_limit and not claims and not marker_problems and not plain_words_note: return parts = [] if plain_words_note: parts.append(plain_words_note) - if claim: - parts.append( - f"This message makes an unverified-shaped claim (\"{claim}\") with no " - "adjacent evidence (pasted command output, or a " - f"`{markers.TAG_TEMPLATE}` tag). A backticked name or command alone " - "is not output. Per skills/prove-it/SKILL.md: either paste the " - "output of what was actually run/checked, or -- only if the check " - "cannot run -- tag the claim and say why." + if claims: + lines = [ + "This message makes an unverified-shaped claim with no adjacent " + "evidence (pasted command output, or a " + f"`{markers.TAG_TEMPLATE}` tag) in the same paragraph. Every " + f"flagged sentence ({len(claims)}):" + ] + for number, (phrase, sentence) in enumerate(claims, 1): + lines.append(f"{number}. \"{sentence}\" (trigger: \"{' '.join(phrase.split())}\")") + lines.append( + "A backticked name or command alone is not output. Per " + "skills/prove-it/SKILL.md: for each one, either paste the output " + "of what was actually run/checked in its paragraph, or -- only if " + "the check cannot run -- tag the claim there and say why." ) + parts.append("\n".join(lines)) parts.extend(marker_problems) if over_limit: parts.append( diff --git a/engine/hooks/diu-stop/tests/test_hooks.py b/engine/hooks/diu-stop/tests/test_hooks.py index 1ba9764b..ca2fea71 100644 --- a/engine/hooks/diu-stop/tests/test_hooks.py +++ b/engine/hooks/diu-stop/tests/test_hooks.py @@ -311,6 +311,42 @@ def test_tag_excuses_only_its_own_paragraph(self): ) self.assertEqual(claude_stop_check.find_unverified_claim(message), "because") + LOCK_CLAIM = "The owner crashed because the lock never released." + CACHE_CLAIM = "The deploy was stale because the cache never cleared." + LOCK_TAG = "{{CAT-UNVERIFIED: the lock never released -- cannot verify: the host is powered down}}" + CACHE_TAG = "{{CAT-UNVERIFIED: the cache never cleared -- cannot verify: the CDN console is unreachable}}" + + def test_one_block_names_every_unproven_sentence(self): + # Fail-before: the block named only the first claim, so a rewrite + # that fixed it was blocked again for the second. + message = f"{self.LOCK_CLAIM}\n\n{self.CACHE_CLAIM}" + blocked, err = run_claude_check({"last_assistant_message": message}) + self.assertTrue(blocked) + self.assertIn(self.LOCK_CLAIM, err) + self.assertIn(self.CACHE_CLAIM, err) + + def test_retry_passes_once_every_named_sentence_is_tagged(self): + message = f"{self.LOCK_CLAIM} {self.LOCK_TAG}\n\n{self.CACHE_CLAIM} {self.CACHE_TAG}" + blocked, err = run_claude_check({"last_assistant_message": message, "stop_hook_active": True}) + self.assertFalse(blocked) + self.assertEqual(err, "") + + def test_retry_names_only_the_sentence_still_unproven(self): + message = f"{self.LOCK_CLAIM} {self.LOCK_TAG}\n\n{self.CACHE_CLAIM}" + blocked, err = run_claude_check({"last_assistant_message": message, "stop_hook_active": True}) + self.assertTrue(blocked) + self.assertIn(self.CACHE_CLAIM, err) + self.assertNotIn(self.LOCK_CLAIM, err) + + def test_long_flagged_sentence_is_cut_to_120_characters(self): + message = "Setup finished. The owner crashed because " + "the lock never released and " * 10 + "stayed down." + claims = claude_stop_check.find_unverified_claims(message) + self.assertEqual(len(claims), 1) + phrase, sentence = claims[0] + self.assertEqual(phrase, "because") + self.assertEqual(len(sentence), 120) + self.assertTrue(sentence.startswith("The owner crashed because")) + def test_ordinary_message_without_banned_language_passes(self): blocked, err = run_claude_check({"last_assistant_message": "I'll check the logs next and report back."}) self.assertFalse(blocked)