diff --git a/engine/hooks/wrong-check-reflect/README.md b/engine/hooks/wrong-check-reflect/README.md index 02db976a..1b2a32d9 100644 --- a/engine/hooks/wrong-check-reflect/README.md +++ b/engine/hooks/wrong-check-reflect/README.md @@ -12,7 +12,8 @@ up. If the judge result was unchecked, the inbox reports "could not judge" instead of treating the reply as clean. Finish the live correction first. Fail-open. -Once per transcript. Skip if the user already said `/reflect`. +Once per reply. Skip if the user asked for `/reflect` in the same turn that +produced that reply. Not word-count (`diu-stop`). Not token_audit thrash (`reflect-on-thrash`). Assistant text only - user messages and fenced code stay silent. @@ -24,9 +25,26 @@ builds a phrase-dictionary job, and sends it to `llm-judge`. The dictionary defines the meaning with `match` and `not_match` examples and supplies the static `on_hit` follow-up text. -No job is sent when `stop_hook_active` is set, when this transcript or reply -was already prompted, when the reply is empty, or when the user already asked -for `/reflect`. Inside a judge run (`CATSTACK_LLM_JUDGE_CHILD=1`) `llm-judge` +No job is sent when `stop_hook_active` is set, when this exact reply was +already prompted, when the reply is empty, or when the user asked for +`/reflect` in the turn that produced this reply. Only the person counts: the +harness files its own injections as `type: "user"` rows carrying `isMeta`, so +a Stop hook's own feedback and a skill's injected body are read as harness +text, not as the user asking. A typed `/reflect` is not harness text: the +harness writes it as `` inside the person's own row, so it +still counts as the person asking. Before that, `diu-stop`'s block text and the +reflect skill's own body both said "reflect" and switched this hook off. + +The one-shot key is the +transcript path plus a hash of the reply text: keyed on the transcript alone, +the Stop of the reply *before* a correction spent the key, and the correction +a minute later found itself already prompted. The `/reflect` scan is scoped to +the current turn for the same reason -- scanning the whole transcript let one +`/reflect` switch the hook off for the rest of the session. That turn runs +from the person's own last message to the end of the file, never from the +last assistant row: a Stop carries the reply before its row is written, so +the last assistant row is the turn before's, and a turn writes several +assistant rows anyway (narration, a subagent's sidechain). Inside a judge run (`CATSTACK_LLM_JUDGE_CHILD=1`) `llm-judge` refuses the job. The model call runs in a detached background process, so the reply is never @@ -50,7 +68,7 @@ pattern to this hook; the prose meaning belongs in the phrase dictionary. ## Files -- `detect.py` - judge enqueue + once-per-transcript state +- `detect.py` - judge enqueue + once-per-reply state - `claude_stop_check.py` — Claude `Stop` (stderr + exit 2) - `cursor_session.py` — Cursor `stop` / `sessionEnd` (`followup_message`) - `codex_notify.py` — Codex `notify` (advisory print + chain) diff --git a/engine/hooks/wrong-check-reflect/detect.py b/engine/hooks/wrong-check-reflect/detect.py index e49ab3b1..8bf94422 100644 --- a/engine/hooks/wrong-check-reflect/detect.py +++ b/engine/hooks/wrong-check-reflect/detect.py @@ -24,8 +24,12 @@ os.path.join(os.path.expanduser("~"), ".cache", "catstack-wrong-check-reflect"), ) -ALREADY_REFLECT_RE = re.compile(r"(?i)\b/?reflect\b|\b/?automate-me\b|\bautomate me\b") -META_USER_PREFIXES = ("\s*/?(?:reflect|automate-me)\b" + r"|\s*(?:reflect|automate-me)\s*" +) +META_USER_PREFIXES = ( + " str: - key = transcript_path or "no-transcript" - digest = hashlib.sha1(os.path.abspath(key).encode()).hexdigest()[:16] +def reply_key(transcript_path: str, text: str) -> str: + """One-shot key for a single reply, not for a whole session. + + Keying on the transcript alone made the hook fire at most once per + session, and the Stop that spent the key was the Stop of the reply + BEFORE the correction -- so the correction itself, a minute later, was + already marked as prompted. A reply is the thing being judged, so the + reply's text is what the key is made of. The transcript stays in the key + so the same sentence in two sessions is two chances, not one. + """ + base = os.path.abspath(transcript_path) if transcript_path else "no-transcript" + return base + "\n" + hashlib.sha256((text or "").encode("utf-8")).hexdigest() + + +def _state_file(key: str) -> str: + digest = hashlib.sha1((key or "no-transcript").encode()).hexdigest()[:16] return os.path.join(STATE_DIR, f"{digest}.prompted") -def already_prompted(transcript_path: str) -> bool: - return os.path.isfile(_state_file(transcript_path or "no-transcript")) +def already_prompted(key: str) -> bool: + return os.path.isfile(_state_file(key or "no-transcript")) -def mark_prompted(transcript_path: str) -> None: - path = _state_file(transcript_path or "no-transcript") +def mark_prompted(key: str) -> None: + path = _state_file(key or "no-transcript") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: - handle.write((transcript_path or "") + "\n") + handle.write((key or "") + "\n") def _is_user_line(data: dict) -> bool: @@ -60,6 +77,43 @@ def _is_user_line(data: dict) -> bool: return isinstance(message, dict) and message.get("role") == "user" +def _is_tool_result_line(data: dict) -> bool: + """True for a user-shaped row that is only a tool's output. + + Claude Code files every tool result as `type: "user"` and, unlike its + other injections, marks it with neither `isMeta` nor `isSidechain` -- the + record it does carry is a `tool_result` content block, plus a + `toolUseResult` field alongside the message. Reading that is the same + move `engine/hooks/agent-relay-attribution/detect.py:76` makes. + """ + if data.get("toolUseResult") is not None: + return True + message = data.get("message") + content = message.get("content") if isinstance(message, dict) else data.get("content") + return isinstance(content, list) and any( + isinstance(block, dict) and block.get("type") == "tool_result" for block in content + ) + + +def _is_meta_line(data: dict) -> bool: + """True for a user-shaped row that is not the person speaking. + + The harness files its own injections as `type: "user"`: a Stop hook's + feedback, a skill's body, a subagent's transcript, a tool's result. Each + carries a record saying so -- `isMeta`, which is what + `engine/skills/reflect/scripts/token_audit.py:313` keys off, or the + `tool_result` shape above -- so this reads the record instead of the + prose. A prose prefix could only ever catch the wordings someone had + already seen -- and it missed both the hook feedback and the reflect + skill's own body, which is how running `/reflect` disarmed this hook. + """ + if data.get("isMeta") or data.get("agentId") or data.get("isSidechain"): + return True + if _is_tool_result_line(data): + return True + return _message_text(data).lstrip().startswith(META_USER_PREFIXES) + + def _message_text(data: dict) -> str: message = data.get("message") content = message.get("content") if isinstance(message, dict) else data.get("content") @@ -76,9 +130,9 @@ def _message_text(data: dict) -> str: return "" -def user_already_asked_reflect(path: str) -> bool: - if not path or not os.path.isfile(path): - return False +def _transcript_roles(path: str) -> list[tuple[str, str]] | None: + """(role, text) per transcript line, or None when the file cannot be read.""" + rows: list[tuple[str, str]] = [] try: with open(path, encoding="utf-8") as handle: for line in handle: @@ -86,15 +140,68 @@ def user_already_asked_reflect(path: str) -> bool: data = json.loads(line) except json.JSONDecodeError: continue - if not isinstance(data, dict) or not _is_user_line(data): + if not isinstance(data, dict): continue - text = _message_text(data) - if not text or text.lstrip().startswith(META_USER_PREFIXES): - continue - if ALREADY_REFLECT_RE.search(text): - return True - except OSError: + if _is_user_line(data): + rows.append(("meta" if _is_meta_line(data) else "user", _message_text(data))) + elif _is_assistant_line(data): + rows.append(("assistant", _message_text(data))) + except OSError as exc: + print( + f"catstack-hook-error wrong-check-reflect: cannot read {path}, " + f"the user's own reflect request is unchecked: {exc}", + file=sys.stderr, + ) + return None + return rows + + +def user_already_asked_reflect(path: str) -> bool: + """True when the user invoked reflect in the turn that produced this reply. + + Only a real invocation counts, which the harness records as a + `/reflect` envelope. Prose that merely says + the word does not: `"Claim I made was wrong" is a trigger for /reflect` + describes the rule, it does not ask for anything, and suppressing on it + let a sentence about the hook switch the hook off. Erring toward asking + is the safe direction for a detector that spoke 0 times in 1,682 runs. + + Scoped to that one turn on purpose. Scanning the whole transcript meant a + single `/reflect` typed at the start of a session switched the detector + off for every reply after it, however many hours later. + + The turn is the stretch from the person's own last message to the end of + the file. Anchoring it on assistant rows instead was wrong twice over. A + Stop payload carries the reply before its row is written, so the last + assistant row was then the PREVIOUS turn's reply: that turn's `/reflect` + suppressed this one -- the session lockout back, just one turn wide -- + and the `/reflect` on the current message sat after the window and was + ignored. And a turn writes more than one assistant row: mid-turn + narration and a subagent's sidechain rows each pushed the window's start + past the message that opened the turn. The person's message is the row + that actually starts a turn, so it is the anchor; rows after it are this + turn's whether or not the reply has landed yet. + + A tool result is filed as a `type: "user"` row too, so it only counts as + the person speaking if nothing checks -- and then the first tool call of + the turn became the anchor and the `/reflect` that opened the turn fell + outside the window. `_is_meta_line` rules those rows out. + """ + if not path or not os.path.isfile(path): + return False + rows = _transcript_roles(path) + if rows is None: return False + start = 0 + for index in range(len(rows) - 1, -1, -1): + if rows[index][0] == "user": + start = index + break + for role, text in rows[start:]: + if role == "assistant" or not text: + continue + if ALREADY_REFLECT_RE.search(text): + return True return False @@ -192,7 +299,7 @@ def enqueue_judge(payload: dict) -> str | None: return None path = resolve_transcript(payload) text = last_assistant_text(payload, path) - key = path or text[:200] + key = reply_key(path, text) if not text.strip() or already_prompted(key): return None if path and user_already_asked_reflect(path): diff --git a/engine/hooks/wrong-check-reflect/tests/test_hooks.py b/engine/hooks/wrong-check-reflect/tests/test_hooks.py index e3d44630..bf7a5373 100644 --- a/engine/hooks/wrong-check-reflect/tests/test_hooks.py +++ b/engine/hooks/wrong-check-reflect/tests/test_hooks.py @@ -31,6 +31,7 @@ PY = sys.executable +REFLECT_COMMAND = "reflect/reflect" HIT_TEXT = "Correction: the file I pointed you to earlier is not the one in use; the real one is src/b.py." OPTION_TEXT = "You're right. Let's go with option B." COUNT_TEXT = "I double-checked my earlier count and it holds; nothing in it was wrong." @@ -71,7 +72,35 @@ def run_codex_notify(argv: list[str]) -> str: def transcript_line(role: str, text: str) -> str: - return json.dumps({"type": role, "message": {"role": role, "content": [{"type": "text", "text": text}]}}) + """One transcript row. A role of "meta" is the harness talking, not the user. + + The shape of a meta row is taken from a real Claude Code transcript: the + harness files its Stop-hook feedback and a skill's injected body as + `type: "user"` rows carrying `isMeta: true`. A `sidechain-` prefix files + the row under a subagent, which a real transcript marks with + `isSidechain: true`. + + A role of "tool_result" is the other user-shaped row the harness writes, + and the one it flags with none of those keys: a real transcript gives it + a `tool_result` content block and a `toolUseResult` field, and no + `isMeta`. + """ + if role == "tool_result": + return json.dumps({ + "type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": text}]}, + "toolUseResult": {"stdout": text}, + }) + sidechain = role.startswith("sidechain-") + role = role[len("sidechain-"):] if sidechain else role + meta = role == "meta" + kind = "user" if meta else role + row = {"type": kind, "message": {"role": kind, "content": [{"type": "text", "text": text}]}} + if meta or sidechain: + row["isMeta"] = meta + row["isSidechain"] = sidechain + return json.dumps(row) class TestWrongCheckReflect(JudgeTestCase): @@ -198,15 +227,220 @@ def test_judge_not_enqueued_when_stop_hook_active(self): def test_judge_not_enqueued_when_already_prompted(self): path = self.write_transcript(("assistant", HIT_TEXT)) - detect.mark_prompted(path) + detect.mark_prompted(detect.reply_key(path, HIT_TEXT)) self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) self.assertEqual(self.jobs(), []) def test_judge_not_enqueued_when_user_already_asked_reflect(self): - path = self.write_transcript(("user", "please /reflect"), ("assistant", HIT_TEXT)) + path = self.write_transcript(("user", REFLECT_COMMAND), ("assistant", HIT_TEXT)) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + self.assertEqual(self.jobs(), []) + + def test_prose_about_reflect_does_not_count_as_asking_for_one(self): + """A sentence naming the command is not an invocation of it. + + `"Claim I made was wrong" is a trigger for /reflect` describes when + the hook fires. Treating that as a request let a sentence about the + hook switch the hook off for the rest of the turn. + """ + path = self.write_transcript( + ("user", '"Claim I made was wrong" is a trigger for /reflect'), + ("assistant", HIT_TEXT), + name="prose-mention.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_the_later_correction_still_fires_after_the_reply_before_it(self): + """Lockout one: the Stop of the pre-correction reply spent the key.""" + self.use_runners(SLOW_CLEAN) + turn_one = (("user", "check the path"), ("assistant", "The live file is src/a.py.")) + path = self.write_transcript(*turn_one, name="turn.jsonl") + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + self.write_transcript( + *turn_one, + ("user", "are you sure?"), + ("assistant", HIT_TEXT), + name="turn.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_reflect_asked_earlier_in_the_session_does_not_silence_a_later_reply(self): + """Lockout two: one /reflect used to switch the hook off for good.""" + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "please /reflect on the last hour"), + ("assistant", "Here is the reflect write-up."), + ("user", "now fix the import"), + ("assistant", HIT_TEXT), + name="long.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_reflect_asked_in_this_same_turn_is_still_not_doubled_up(self): + path = self.write_transcript( + ("user", "fix the import"), + ("assistant", "Done."), + ("user", "that was wrong. " + REFLECT_COMMAND), + ("assistant", HIT_TEXT), + name="same-turn.jsonl", + ) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + self.assertEqual(self.jobs(), []) + + def test_the_same_reply_is_never_queued_twice(self): + self.use_runners(SLOW_CLEAN) + path = self.write_transcript(("assistant", HIT_TEXT), name="dedup.jsonl") + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + + def test_the_same_reply_is_judged_under_both_spellings_of_the_stop_event(self): + """Harness parity: no branch may turn on the case of the event name.""" + self.use_runners(SLOW_CLEAN) + queued = {} + for spelling in ("Stop", "stop"): + path = self.write_transcript( + ("assistant", HIT_TEXT), name=f"parity-{spelling}.jsonl") + queued[spelling] = detect.enqueue_judge( + {"transcript_path": path, "hook_event_name": spelling}) + self.assertIsNotNone(queued["Stop"]) + self.assertIsNotNone(queued["stop"]) + + def test_a_stop_hook_feedback_line_does_not_suppress_the_hook(self): + """The ecosystem used to silence itself: diu-stop's own block says reflect.""" + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "fix the import"), + ("meta", "Stop hook feedback: [diu-stop/claude_stop_check.py]: read the " + "reflect skill and say why"), + ("assistant", HIT_TEXT), + name="hook-feedback.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_the_reflect_skills_own_body_does_not_suppress_the_hook(self): + """Running /reflect used to disarm the detector that asks for it.""" + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "fix the import"), + ("meta", "Base directory for this skill: ~/.claude/skills/reflect\n# Reflect"), + ("assistant", HIT_TEXT), + name="skill-body.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_a_real_user_reflect_request_in_the_same_turn_still_suppresses(self): + path = self.write_transcript( + ("user", "fix the import"), + ("assistant", "Done."), + ("user", "that was wrong. " + REFLECT_COMMAND), + ("meta", "Stop hook feedback: unrelated"), + ("assistant", HIT_TEXT), + name="real-request.jsonl", + ) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + self.assertEqual(self.jobs(), []) + + def test_a_reflect_this_turn_counts_before_the_reply_row_is_written(self): + """The Stop payload carries the reply; its transcript row is not there yet. + + Anchoring the window on the last assistant row made that row the + PREVIOUS turn's reply, so the `/reflect` the person typed a moment + ago sat past the window and the hook nagged for a reflect already + in flight. + """ + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "fix the import"), + ("assistant", "Done."), + ("user", "that was wrong. " + REFLECT_COMMAND), + name="reply-row-not-written.jsonl", + ) + self.assertIsNone(detect.enqueue_judge( + {"transcript_path": path, "last_assistant_message": HIT_TEXT})) + self.assertEqual(self.jobs(), []) + + def test_last_turns_reflect_does_not_silence_a_reply_still_being_written(self): + """The same stale window, pointing the other way: a one-turn lockout.""" + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "that was wrong. " + REFLECT_COMMAND), + ("assistant", "Here is the reflect write-up."), + ("user", "now fix the import"), + name="stale-window.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge( + {"transcript_path": path, "last_assistant_message": HIT_TEXT})) + + def test_mid_turn_narration_does_not_push_the_window_past_the_request(self): + """A turn writes many assistant rows: narration, then the reply. + + Starting the window after the previous assistant row cut the turn's + own opening message out of it, so the `/reflect` in that message was + never seen. + """ + path = self.write_transcript( + ("user", "that was wrong. " + REFLECT_COMMAND), + ("assistant", "Let me open the file first."), + ("assistant", HIT_TEXT), + name="mid-turn-narration.jsonl", + ) self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) self.assertEqual(self.jobs(), []) + def test_a_subagents_reply_row_does_not_hide_the_turns_request(self): + """Sidechain rows land in the same file and used to move the window.""" + path = self.write_transcript( + ("user", "that was wrong. " + REFLECT_COMMAND), + ("assistant", "Spawning a subagent."), + ("sidechain-user", "go read the file"), + ("sidechain-assistant", "The live file is src/b.py."), + ("assistant", HIT_TEXT), + name="sidechain.jsonl", + ) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + self.assertEqual(self.jobs(), []) + + def test_a_tool_result_row_does_not_push_the_window_past_the_request(self): + """Claude Code files a tool result as a `type: "user"` row with no `isMeta`. + + Treating it as the person speaking made the turn's first tool call + the start of the window, so the `/reflect` typed at the top of the + turn sat before it and the hook nagged for a reflect already asked + for. + """ + path = self.write_transcript( + ("user", "that was wrong. " + REFLECT_COMMAND), + ("assistant", "Let me open the file first."), + ("tool_result", "def parse_args(argv):\n return argv[1]\n"), + ("assistant", HIT_TEXT), + name="tool-result.jsonl", + ) + self.assertIsNone(detect.enqueue_judge({"transcript_path": path})) + self.assertEqual(self.jobs(), []) + + def test_a_tool_result_quoting_reflect_is_not_a_request_for_one(self): + """Grepping the hook's own source prints the word; that is not an ask.""" + self.use_runners(SLOW_CLEAN) + path = self.write_transcript( + ("user", "grep the hook"), + ("assistant", "Running grep."), + ("tool_result", "detect.py:31:ALREADY_REFLECT_RE = re.compile(r\"/reflect\")"), + ("assistant", HIT_TEXT), + name="tool-result-prose.jsonl", + ) + self.assertIsNotNone(detect.enqueue_judge({"transcript_path": path})) + + def test_unreadable_transcript_reports_unchecked_instead_of_going_quiet(self): + missing = os.path.join(self.reflect_state.name, "does-not-exist.jsonl") + self.assertFalse(detect.user_already_asked_reflect(missing)) + directory = os.path.join(self.reflect_state.name, "a-directory.jsonl") + os.makedirs(directory, exist_ok=True) + err = io.StringIO() + with redirect_stderr(err): + rows = detect._transcript_roles(directory) + self.assertIsNone(rows) + self.assertIn("unchecked", err.getvalue()) + def test_claude_malformed_stdin_fail_open(self): err = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not-json")): diff --git a/engine/skills/draft-pr/tests/test_draft_pr_scripts.py b/engine/skills/draft-pr/tests/test_draft_pr_scripts.py index 22b5b8a8..6283cd4d 100644 --- a/engine/skills/draft-pr/tests/test_draft_pr_scripts.py +++ b/engine/skills/draft-pr/tests/test_draft_pr_scripts.py @@ -212,6 +212,29 @@ def test_hyphenated_english_words_are_not_code_names(self): self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertNotIn(CODE_NAME_ERROR, result.stderr) + def test_changed_file_stem_is_a_code_name_even_when_it_reads_like_english(self): + files = ["engine/hooks/wrong-check-reflect/detect.py", "tests/scenarios/self-correction.json"] + summary = ( + "The nudge for a review after a self-correction has never once spoken: " + "1,682 runs, zero. Four separate things silenced it, and each one alone " + "was enough to keep it quiet." + ) + result = _run_validator(self._engine_body(summary), files) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn(CODE_NAME_ERROR, result.stderr) + self.assertIn('"self-correction" (changed file name)', result.stderr) + + def test_rewording_the_changed_file_stem_clears_the_failure(self): + files = ["engine/hooks/wrong-check-reflect/detect.py", "tests/scenarios/self-correction.json"] + summary = ( + "The nudge for a review after the assistant takes back a wrong claim has " + "never once spoken: 1,682 runs, zero. Four separate things silenced it, " + "and each one alone was enough to keep it quiet." + ) + result = _run_validator(self._engine_body(summary), files) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn(CODE_NAME_ERROR, result.stderr) + def test_missing_summary_is_reported_unchecked_not_clean(self): body = VALID_BODY.replace(f"## Summary\n\n{VALID_SUMMARY}\n\n", "") self.assertNotIn("## Summary", body) diff --git a/tests/scenarios/self-correction.json b/tests/scenarios/self-correction.json index 8d41bd49..d5886be4 100644 --- a/tests/scenarios/self-correction.json +++ b/tests/scenarios/self-correction.json @@ -19,11 +19,20 @@ }, { "name": "admission-skipped-when-user-already-said-reflect", - "situation": "Pins the documented skip: ALREADY_REFLECT_RE suppresses the follow-up when the user's own message already asked for /reflect, so the hook does not nag for something already in flight. Caught by writing the scenario above with the user's literal message, which contained /reflect.", - "user": "\"Claim I made was wrong\" is a trigger for /reflect", + "situation": "Pins the documented skip: the hook does not nag for a reflect the user has already asked for. The user's message must be an actual request. An earlier version of this scenario used a message that only mentioned /reflect while describing the rule, so it pinned the suppression against a sentence that never asked for anything; the mention case is now its own scenario below.", + "user": "reflect/reflecton this", "reply": "Also: a claim I made earlier was wrong. The coverage run compared against origin/main rather than the slice, so the pass was vacuous.", "expect_no_enqueue": [ "wrong-check-reflect" ] + }, + { + "name": "admission-still-caught-when-reflect-is-only-mentioned", + "situation": "The mention-is-not-a-use half of the same rule. Quoting the word /reflect while describing when it fires is not a request for one, so an admission in the same turn must still reach the judge. This is the exact text the previous scenario used to suppress on.", + "user": "\"Claim I made was wrong\" is a trigger for /reflect", + "reply": "Also: a claim I made earlier was wrong. The coverage run compared against origin/main rather than the slice, so the pass was vacuous.", + "expect_enqueue": [ + "wrong-check-reflect" + ] } ]