From c00094b9211a3e431a5582fbc2dfea2d80521d6b Mon Sep 17 00:00:00 2001 From: Edbert Chan <1916223+EdbertChan@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:05:51 -0700 Subject: [PATCH 1/2] Flag absence and capability claims as unverified-shaped in diu-stop 'has no', 'there is no', 'no single', 'no such call' and 'can't be done' now trigger the unverified-claim check, so a no-such-call claim needs output or a tag like any other claim. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I6f50c350c18dfe9c42a78f4d5d17b48b151af2b9 --- engine/hooks/diu-stop/claude_stop_check.py | 8 +++++- .../diu-stop/tests/fixtures/claims_fires.json | 25 +++++++++++++++++++ .../tests/fixtures/claims_silent.json | 12 +++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/engine/hooks/diu-stop/claude_stop_check.py b/engine/hooks/diu-stop/claude_stop_check.py index 1701cfc47..7e123b547 100755 --- a/engine/hooks/diu-stop/claude_stop_check.py +++ b/engine/hooks/diu-stop/claude_stop_check.py @@ -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 @@ -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() diff --git a/engine/hooks/diu-stop/tests/fixtures/claims_fires.json b/engine/hooks/diu-stop/tests/fixtures/claims_fires.json index 3765e413e..2f14008e9 100644 --- a/engine/hooks/diu-stop/tests/fixtures/claims_fires.json +++ b/engine/hooks/diu-stop/tests/fixtures/claims_fires.json @@ -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" } ] diff --git a/engine/hooks/diu-stop/tests/fixtures/claims_silent.json b/engine/hooks/diu-stop/tests/fixtures/claims_silent.json index 5780a76bc..792f7d688 100644 --- a/engine/hooks/diu-stop/tests/fixtures/claims_silent.json +++ b/engine/hooks/diu-stop/tests/fixtures/claims_silent.json @@ -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." } ] From 9ab47850dbbeb9b54ab450d1b424fd0fdf3375a6 Mon Sep 17 00:00:00 2001 From: Edbert Chan <1916223+EdbertChan@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:06:17 -0700 Subject: [PATCH 2/2] Cite the cat-mode rule by quote, not line number, and test the quote exists The hooks cited cat-mode/SKILL.md:269, but the phrase is at line 166 of the repo's corpus/skills/cat-mode/SKILL.md. Tests now check the quoted phrase in that file and that no hook source cites a line number. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I3661d7e5e3b3714ef1532b9b4022f232652f1713 --- engine/hooks/unverified-tag-ledger/README.md | 2 +- .../claude_prompt_reminder.py | 4 +-- .../claude_stop_check.py | 2 +- engine/hooks/unverified-tag-ledger/detect.py | 10 +++---- .../unverified-tag-ledger/tests/test_hooks.py | 26 +++++++++++++++++-- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/engine/hooks/unverified-tag-ledger/README.md b/engine/hooks/unverified-tag-ledger/README.md index 08640f73e..7b9ee18a1 100644 --- a/engine/hooks/unverified-tag-ledger/README.md +++ b/engine/hooks/unverified-tag-ledger/README.md @@ -5,7 +5,7 @@ A well-formed `{{CAT-UNVERIFIED: -- cannot verify: }}` 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. diff --git a/engine/hooks/unverified-tag-ledger/claude_prompt_reminder.py b/engine/hooks/unverified-tag-ledger/claude_prompt_reminder.py index 69bd5baca..4405e4a2b 100644 --- a/engine/hooks/unverified-tag-ledger/claude_prompt_reminder.py +++ b/engine/hooks/unverified-tag-ledger/claude_prompt_reminder.py @@ -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), diff --git a/engine/hooks/unverified-tag-ledger/claude_stop_check.py b/engine/hooks/unverified-tag-ledger/claude_stop_check.py index e0fe8569e..674f22832 100644 --- a/engine/hooks/unverified-tag-ledger/claude_stop_check.py +++ b/engine/hooks/unverified-tag-ledger/claude_stop_check.py @@ -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. diff --git a/engine/hooks/unverified-tag-ledger/detect.py b/engine/hooks/unverified-tag-ledger/detect.py index 6c6829bb8..2b85db605 100644 --- a/engine/hooks/unverified-tag-ledger/detect.py +++ b/engine/hooks/unverified-tag-ledger/detect.py @@ -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. @@ -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]: @@ -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 @@ -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.")} @@ -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": ""} diff --git a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py index a88df393a..098c9cc9b 100644 --- a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py +++ b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py @@ -17,6 +17,7 @@ import io import json import os +import re import sys import tempfile import unittest @@ -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 " @@ -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) @@ -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: