Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 78 additions & 27 deletions engine/hooks/diu-stop/claude_stop_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
checks cannot loop that way, because a well-formed
{{CAT-UNVERIFIED: ... -- cannot verify: <reason>}} 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
Expand All @@ -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 = [
Expand Down Expand Up @@ -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()
Expand All @@ -143,16 +155,50 @@ 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
silences its paragraph only when it looks like command output or the
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():
Expand All @@ -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():
Expand All @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions engine/hooks/diu-stop/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading