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
22 changes: 22 additions & 0 deletions engine/hooks/llm-judge/phrases/wrong-check-reflect.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"checker": "wrong-check-reflect",
"reads": "reply",
"meaning": "The latest assistant reply admits that something it told the user earlier was wrong, misread, or answered the wrong question.",
"match": [
"my earlier check was wrong",
"You're right, I misread that",
"I incorrectly assumed that file was unused",
"my mistake",
"I was wrong about the path",
"Correction: the file I pointed you to earlier is not the one in use; the real one is src/b.py.",
"Good catch. The earlier number was off; the real count is 12."
],
"not_match": [
"You're right. Let's go with option B.",
"I double-checked my earlier count and it holds; nothing in it was wrong.",
"If my earlier check was wrong, say so.",
"The test was wrong, not the code.",
"He said I was wrong about the timeout."
],
"on_hit": "Wrong-check admission on this transcript. This is a FAILURE, not a preference ping: a claim went out before a real check. Finish the live correction first. Then read the reflect skill and spawn a subagent for steps 1-4 on this exact transcript. Present Accepted / Backlog / Route-to-automate-me / Rejected. Do not skip because the task also finished."
}
68 changes: 29 additions & 39 deletions engine/hooks/wrong-check-reflect/README.md
Original file line number Diff line number Diff line change
@@ -1,66 +1,56 @@
# wrong-check-reflect

When the assistant admits a prior check/claim was wrong
("Good catch — my earlier check was wrong", "You're right, I misread the file",
"I incorrectly assumed…", "the file I cited was a duplicate", "My mistake — I
misread it", "I misread the front matter on that skill"), inject a
`/reflect` follow-up.

A reply that opens with a standalone "You're right." (or "You are right —")
counts: it concedes that the user caught something the agent's own checks
did not. "You're right that option B is cheaper" is agreement with a claim
and stays silent. "I misread / misunderstood / mixed up" counts with any
object ("I misread which diff you meant"), not only it/that/the.

A bare "I was wrong" counts, with no named check after it. The retraction
that follows a false claim is often the shortest sentence in the turn, and
requiring it to name the check it retracts let the plainest concession
through. The hypothetical ("if I was wrong about this…"), reported-speech
("the reviewer said I was wrong"), product-blame ("the test was wrong"),
third-person, quote, backtick and fence guards all still hold, so only an
admission asserted in the agent's own voice fires. Finish the live correction first. Fail-open.
When the assistant takes back an earlier check or claim, inject a `/reflect`
follow-up on a later turn.

The hook does not decide that from local wording rules. On every Stop it hands
the last assistant reply to the background judge using
[`engine/hooks/llm-judge/phrases/wrong-check-reflect.json`](../llm-judge/phrases/wrong-check-reflect.json).
A hit arrives on a later turn through the shared [`llm-judge`](../llm-judge/README.md)
inbox and carries the dictionary's `on_hit` text. The live reply is never held
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`.

Not word-count (`diu-stop`). Not token_audit thrash (`reflect-on-thrash`).
Assistant text only user messages and fenced code stay silent.
Assistant text only - user messages and fenced code stay silent.

## Model-judged path

The regexes keep missing new wordings. So when they stay silent, the hook
also asks a small model, through the shared [`llm-judge`](../llm-judge/README.md):
did the user push back, and did the reply take something back?

`enqueue_judge` in `detect.py` reads the transcript and takes three messages:
the current reply, the user message before it, and the assistant message before
that. Each is cut to its last 4000 characters and put under the labels
`EARLIER ASSISTANT`, `USER` and `ASSISTANT` in a prompt that asks for one line
of JSON: `pushback`, `self_correction`, and a `quote`. It is a hit only when
both `pushback` and `self_correction` are `true`.
`enqueue_judge` in `detect.py` reads the transcript, takes the current reply,
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 the regex already fired,
when this transcript was already prompted, or when any of the three messages is
missing (for example, on the first user message, or when the payload names no
transcript). Inside a judge run
(`CATSTACK_LLM_JUDGE_CHILD=1`) `llm-judge` refuses the job.
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`
refuses the job.

The model call runs in a detached background process, so the reply is never
held up. Runners are tried in `llm-judge` order: `codex` (gpt-5.3-codex-spark),
then `claude` (haiku, hooks off), then `cursor-agent`, first answer wins.

The verdict reports one turn later. On the next prompt the `llm-judge` inbox
shows a hit as the same reflect follow-up, with `model judge` as the match. If
no runner could answer, the inbox says so instead of staying quiet. A clean
verdict shows nothing.
shows a hit as the dictionary's `on_hit` text. If no runner could answer, or
the result could not be checked, the inbox says "could not judge" instead of
staying quiet. A clean verdict shows nothing.

`llm-judge` is loaded from the sibling folder (`../llm-judge/judge.py`), which
sits next to this one in the repo and in each harness's `hooks/` folder. If it
cannot be loaded, or the transcript cannot be read, the hook writes
`wrong-check-reflect: judge enqueue failed: <error>` to stderr and its exit
status and output stay the same.

To grow coverage, add the real text of any miss to the dictionary's `match`
phrases, or the real text of any false alarm to `not_match`. Do not add a
pattern to this hook; the prose meaning belongs in the phrase dictionary.

## Files

- `detect.py` — shared admission regex + once-per-transcript state
- `detect.py` - judge enqueue + once-per-transcript 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
18 changes: 3 additions & 15 deletions engine/hooks/wrong-check-reflect/claude_stop_check.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
#!/usr/bin/env python3
"""Claude Code Stop hook: inject reflect on first-person wrong-check admission.

Exit 2 with the reflect prompt when the last assistant message admits a prior
check/claim was wrong. Fail-open. When the regex stays silent, ask the
background llm-judge instead; its verdict arrives on the next prompt.
"""
"""Claude Code Stop hook for wrong-check-reflect."""
from __future__ import annotations

import json
import sys

from detect import decide, try_enqueue_judge
from detect import try_enqueue_judge


def main() -> None:
Expand All @@ -19,14 +14,7 @@ def main() -> None:
except (json.JSONDecodeError, OSError):
return
payload = payload if isinstance(payload, dict) else {}
try:
message = decide(payload)
except Exception:
return
try_enqueue_judge(payload, bool(message))
if message:
sys.stderr.write(message + "\n")
sys.exit(2)
try_enqueue_judge(payload)


if __name__ == "__main__":
Expand Down
17 changes: 3 additions & 14 deletions engine/hooks/wrong-check-reflect/codex_notify.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
#!/usr/bin/env python3
"""Codex `notify` hook: advisory wrong-check admission heads-up.

Codex fires notify after the turn is over — no way to block or force a
rewrite. Print a heads-up; chain to any prior notify command. When the regex
stays silent and the payload names a transcript, ask the background llm-judge.

notify = ["python3", "/path/to/codex_notify.py", "/path/to/old-notify", ...]
"""
"""Codex notify hook for wrong-check-reflect."""
from __future__ import annotations

import json
import subprocess
import sys

from detect import CODEX_ADVISORY, find_admission, try_enqueue_judge
from detect import try_enqueue_judge


def main() -> None:
Expand All @@ -36,11 +29,7 @@ def main() -> None:
if payload.get("type") != "agent-turn-complete":
return

message = payload.get("last-assistant-message") or ""
match = find_admission(message)
try_enqueue_judge(payload, bool(match))
if match:
print(CODEX_ADVISORY.format(match=match), file=sys.stderr)
try_enqueue_judge(payload)


if __name__ == "__main__":
Expand Down
19 changes: 4 additions & 15 deletions engine/hooks/wrong-check-reflect/cursor_session.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
#!/usr/bin/env python3
"""Cursor stop / sessionEnd for wrong-check-reflect.

`stop` delivers followup_message when the last assistant message admits a
prior check was wrong. `sessionEnd` stays silent if already prompted.
Fail-open. When the regex stays silent, ask the background llm-judge instead;
its verdict arrives on the next turn.
"""
"""Cursor stop / sessionEnd hook for wrong-check-reflect."""
from __future__ import annotations

import json
import sys

from detect import decide, try_enqueue_judge
from detect import try_enqueue_judge


def main() -> None:
Expand All @@ -21,13 +15,8 @@ def main() -> None:
print(json.dumps({"followup_message": ""}))
return
payload = payload if isinstance(payload, dict) else {}
try:
message = decide(payload)
except Exception:
print(json.dumps({"followup_message": ""}))
return
try_enqueue_judge(payload, bool(message))
print(json.dumps({"followup_message": message or ""}))
try_enqueue_judge(payload)
print(json.dumps({"followup_message": ""}))


if __name__ == "__main__":
Expand Down
Loading
Loading