Skip to content
Closed
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
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