Skip to content
Merged
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
8 changes: 7 additions & 1 deletion engine/hooks/diu-stop/claude_stop_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@
re.IGNORECASE | re.DOTALL,
)

ABSENCE_CLAIM_RE = re.compile(
r"\b(?:has|have|had) no\b|\bthere (?:is|are|was|were) no\b|\bno single\b"
r"|\bno such (?:call|api|endpoint|function|way)\b|\b(?:can't|cannot|can not) be done\b",
re.IGNORECASE,
)

SENTENCE_END_RE = re.compile(r"(?<=[.!?])\s+")
SENTENCE_LIMIT = 120

Expand Down Expand Up @@ -208,7 +214,7 @@ def _paragraph_claim(para):
opener = _opening_word(para)
if opener in BANNED_OPENERS:
return opener, 0
for pattern in (CAUSAL_CLOSER_RE, HEDGE_CLAIM_RE):
for pattern in (CAUSAL_CLOSER_RE, HEDGE_CLAIM_RE, ABSENCE_CLAIM_RE):
match = pattern.search(para)
if match:
return match.group(0), match.start()
Expand Down
25 changes: 25 additions & 0 deletions engine/hooks/diu-stop/tests/fixtures/claims_fires.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,30 @@
"label": "which is why, the message's only fence holds a command, not output",
"reply": "Both commands are in, and tools are **still** blocked. I also can't read the hook to diagnose it — the hook blocks that read too.\n\n**Run this and paste the output:**\n\n```\n! cat ~/.claude/hooks/scope-lock/claude_pretool_scope.py\n```\n\nThat shows me what actually clears the lock. Right now I'm guessing, and guessing is what got us here.\n\nWorth noting for the findings: a gate that blocks the read of its own source can't be diagnosed from inside the session it's gating — same shape as the `disable-model-invocation` rule already in your corpus, which is why that rule says the fix must live outside the thing it protects.",
"hit": "which is why"
},
{
"label": "absence claim, has no",
"reply": "GitLab has no call that returns a merge request's whole job graph, so the client has to stitch it together.",
"hit": "has no"
},
{
"label": "absence claim, there is no",
"reply": "There is no way to read pipeline stages in one request from the public API.",
"hit": "there is no"
},
{
"label": "absence claim, no single",
"reply": "I found no single endpoint that lists every job with its needs.",
"hit": "no single"
},
{
"label": "absence claim, no such call",
"reply": "The SDK exposes no such call for listing job dependencies.",
"hit": "no such call"
},
{
"label": "capability claim, can't be done",
"reply": "Merging the two requests can't be done without changing the schema.",
"hit": "can't be done"
}
]
12 changes: 12 additions & 0 deletions engine/hooks/diu-stop/tests/fixtures/claims_silent.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,17 @@
{
"label": "because with a backticked assignment, the message carries a fenced block of output",
"reply": "**Correcting myself — it's alive.** The harness reported the wrapper exiting, not the job:\n\n```\n$ pgrep -af replay_causality\n292765 uv run python scripts/replay_causality.py\n292775 .venv/bin/python3 scripts/replay_causality.py\n\n$ tail -2 /tmp/replay.log\nreplaying 400,156 bars, full recompute at each\n 2,000/400,156 bars, 0 mismatches\n```\n\n**2,000 of 400,156 done, 0 mismatches.** Log looks stalled only because `PROGRESS_EVERY = 2000`."
},
{
"label": "absence claim next to fenced output",
"reply": "The schema has no `needs` field:\n\n```\n$ grep -c needs schema.graphql\n0\n```"
},
{
"label": "absence claim carrying a well-formed tag",
"reply": "{{CAT-UNVERIFIED: GitLab has no single call for the job graph -- cannot verify: the GraphQL docs page timed out twice}}"
},
{
"label": "plain reply with no absence phrase",
"reply": "Ed said the box is fine and the run is done."
}
]
2 changes: 1 addition & 1 deletion engine/hooks/unverified-tag-ledger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A well-formed `{{CAT-UNVERIFIED: <claim> -- cannot verify: <reason>}}` tag is a

## The gap it closes

`cat-mode/SKILL.md:269` says:
`cat-mode/SKILL.md` says:

> Any hedge auto-runs prove-it in the same turn — a hedge is a trigger to
> verify, never a place to stop.
Expand Down
4 changes: 2 additions & 2 deletions engine/hooks/unverified-tag-ledger/claude_prompt_reminder.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Claude Code UserPromptSubmit hook: surface CAT-UNVERIFIED claims that
earlier turns deferred and never settled. This is where cat-mode/SKILL.md:269
gets teeth -- the Stop hook cannot block the turn that emits a tag without
earlier turns deferred and never settled. This is where cat-mode/SKILL.md ("a hedge is a trigger to
verify, never a place to stop") gets teeth -- the Stop hook cannot block the turn that emits a tag without
deadlocking, so the reminder lands on the next prompt instead.

How much it says is CATSTACK_UNVERIFIED_TAG_BEHAVIOR: off, stale (default),
Expand Down
2 changes: 1 addition & 1 deletion engine/hooks/unverified-tag-ledger/claude_stop_check.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Claude Code Stop hook: record well-formed CAT-UNVERIFIED tags against the
session, and refuse a turn that tags a claim without having run any
verification tool (cat-mode/SKILL.md:269 -- a hedge is a trigger to verify).
verification tool (cat-mode/SKILL.md -- a hedge is a trigger to verify).
`stop_hook_active` releases that refusal so the rewrite turn can finish. With
CATSTACK_UNVERIFIED_TAG_BEHAVIOR=do_not_emit it also refuses any reply that
carries a tag. Fails open on read or parse errors.
Expand Down
10 changes: 5 additions & 5 deletions engine/hooks/unverified-tag-ledger/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

Every other evidence hook treats `markers.well_formed_tags(message)` as
equivalent to evidence and goes silent (see prove-it-ship-gate/detect.py).
cat-mode/SKILL.md:269 says the opposite: "Any hedge auto-runs prove-it in the
cat-mode/SKILL.md says the opposite: "Any hedge auto-runs prove-it in the
same turn -- a hedge is a trigger to verify, never a place to stop." Nothing
reconciled the two, so a correctly-formed tag was a free, unlogged exit.

Expand Down Expand Up @@ -229,7 +229,7 @@ def reminder(session_id: str, mode: str = DEFAULT_BEHAVIOR_MODE) -> str:
lines = [
"unverified-tag-ledger: "
f"{len(open_rows)} CAT-UNVERIFIED claim(s) from earlier turns are still unverified.",
"cat-mode/SKILL.md:269 -- a hedge is a trigger to verify, never a place to stop. "
"cat-mode/SKILL.md -- a hedge is a trigger to verify, never a place to stop. "
"The tag deferred these; it did not settle them.",
]
for row in open_rows[:MAX_LISTED]:
Expand Down Expand Up @@ -275,7 +275,7 @@ def handback_offers(message: str) -> list[str]:
def evaluate(payload: dict, mode: str | None = None) -> dict:
"""Record the turn, then decide whether it may end.

A tag earns its place only after an attempt. cat-mode/SKILL.md:269 asks for
A tag earns its place only after an attempt. cat-mode/SKILL.md asks for
a verify in the SAME turn, so a tag emitted by a turn that ran no
verification tool is a claim nobody tried to check, and that turn is
refused. Requiring an attempt is not requiring success: run the check, and
Expand Down Expand Up @@ -350,7 +350,7 @@ def evaluate(payload: dict, mode: str | None = None) -> dict:
return {"note": "", "block": (
"unverified-tag-ledger: this turn tags a claim as unverified but ran no "
f"verification tool. Untried claim(s): {claims}. "
"cat-mode/SKILL.md:269 -- a hedge is a trigger to verify, never a place to stop. "
"cat-mode/SKILL.md -- a hedge is a trigger to verify, never a place to stop. "
"Run the check now (Bash/Read/Grep/Glob) and paste its output. The tag is for a "
"check that was attempted and could not settle the claim, not for one nobody ran.")}

Expand All @@ -360,7 +360,7 @@ def evaluate(payload: dict, mode: str | None = None) -> dict:
notes.append(
f"unverified-tag-ledger: logged {len(fresh)} CAT-UNVERIFIED claim(s) against this "
"session. They are deferred, not discharged, and will be raised again next turn "
"(cat-mode/SKILL.md:269).")
"(cat-mode/SKILL.md: a hedge is a trigger to verify).")
return {"note": "\n".join(notes), "block": ""}


Expand Down
26 changes: 24 additions & 2 deletions engine/hooks/unverified-tag-ledger/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import io
import json
import os
import re
import sys
import tempfile
import unittest
Expand All @@ -33,6 +34,9 @@
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(HOOK))), ".env"),
])

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HOOK)))
CAT_MODE_PHRASE = "a hedge is a trigger to verify, never a place to stop"

REAL_TAG_1 = (
"{{CAT-UNVERIFIED: that it widened scope past the one session I gave it "
"-- cannot verify: it never answered when asked twice; its "
Expand Down Expand Up @@ -167,9 +171,27 @@ def test_tag_after_a_real_attempt_is_logged_and_allowed(self) -> None:
def test_tag_with_no_attempt_is_blocked(self) -> None:
verdict = self.detect.evaluate(self.payload(REAL_TAG_1, tools=False))
self.assertIn("ran no verification tool", verdict["block"])
self.assertIn("cat-mode/SKILL.md:269", verdict["block"])
self.assertIn("a hedge is a trigger to verify", verdict["block"])
self.assertIn("widened scope", verdict["block"])

def test_the_quoted_rule_exists_in_the_cat_mode_skill_this_repo_owns(self) -> None:
skill = os.path.join(REPO_ROOT, "corpus", "skills", "cat-mode", "SKILL.md")
with open(skill, encoding="utf-8") as handle:
text = " ".join(handle.read().split())
self.assertIn(CAT_MODE_PHRASE, text)

def test_no_hook_source_cites_a_cat_mode_line_number(self) -> None:
stale = []
for name in sorted(os.listdir(HOOK)):
path = os.path.join(HOOK, name)
if not name.endswith((".py", ".md")) or not os.path.isfile(path):
continue
with open(path, encoding="utf-8") as handle:
for number, line in enumerate(handle, start=1):
if re.search(r"SKILL\.md:\d+", line):
stale.append(f"{name}:{number}")
self.assertEqual(stale, [])

def test_blocked_turn_is_still_recorded(self) -> None:
self.detect.evaluate(self.payload(REAL_TAG_1, tools=False))
self.assertEqual(len(self.detect.outstanding(self.detect.read_ledger("s1"))), 1)
Expand Down Expand Up @@ -271,7 +293,7 @@ def test_reminder_names_the_claim_and_cites_the_rule(self) -> None:
self.detect.record_turn("s1", REAL_TAG_1, set())
text = self.detect.reminder("s1", "all")
self.assertIn("widened scope", text)
self.assertIn("cat-mode/SKILL.md:269", text)
self.assertIn("a hedge is a trigger to verify", text)
self.assertIn("never a place to stop", text)

def test_reminder_is_silent_on_off(self) -> None:
Expand Down
Loading