Skip to content
Merged
28 changes: 23 additions & 5 deletions engine/hooks/wrong-check-reflect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<command-name>` 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
Expand All @@ -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)
Expand Down
149 changes: 128 additions & 21 deletions engine/hooks/wrong-check-reflect/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ("<command-", "<task-notification", "<system")
ALREADY_REFLECT_RE = re.compile(
r"(?i)<command-name>\s*/?(?:reflect|automate-me)\b"
r"|<command-message>\s*(?:reflect|automate-me)\s*</command-message>"
)
META_USER_PREFIXES = (
"<local-command", "<task-notification", "<system", "Stop hook feedback")

FOLLOWUP = (
"Wrong-check admission on this transcript. This is a FAILURE, "
Expand All @@ -36,21 +40,34 @@
)


def _state_file(transcript_path: str) -> 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:
Expand All @@ -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")
Expand All @@ -76,25 +130,78 @@ 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:
try:
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
`<command-name>/reflect</command-name>` 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
Comment thread
cursor[bot] marked this conversation as resolved.
break
for role, text in rows[start:]:
if role == "assistant" or not text:
continue
if ALREADY_REFLECT_RE.search(text):
return True
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
EdbertChan marked this conversation as resolved.
return False


Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading