Skip to content
Draft
2 changes: 1 addition & 1 deletion engine/hooks/_runner/tests/test_machine_deliverable.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _cases(self) -> list[tuple[str, str, str, str, bool]]:
return [
("diu-stop/claude_stop_check.py", str(_fixture("diu-stop", "claims_fires.json")["reply"]), "", "root cause", True),
("wait-needs-wakeup/claude_stop_check.py", str(wait["reply"]),
self._transcript(list(wait["transcript"])), "wait-needs-wakeup", True),
self._transcript(list(wait["transcript"])), "wait-needs-wakeup", False),
("hedge-runs-prove-it/claude_stop_check.py",
"It's probably still on the branch and I don't think the build ran.",
self._transcript([]), "probably", False),
Expand Down
2 changes: 1 addition & 1 deletion engine/hooks/_sdk/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def run_hook(
_write_detector_stderr(event)
duration_ms = _duration_ms(started)
mode, mode_source, finding_modes = effective_finding_modes(hook, event, findings)
_write_findings_file(findings)
_write_findings_file(findings + _unchecked_findings(event))
if event.get("_payload_error") and not findings:
if json_error_stderr_prefix is not None:
print(
Expand Down
2 changes: 1 addition & 1 deletion engine/hooks/diu-stop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ power at that point:
- `claude_prompt_reminder.py` -- the script that hook runs. No LLM. Emits the same short reminder, gated to once per session and once per compaction, and only for prompts the human actually typed (`events.is_human_prompt`, `events.once_per_session_or_compaction` in `_sdk`).
- `cursor_stop_check.py` -- Cursor's `stop` hook command. It uses reply text from the payload when that text is present. Otherwise it reads the last assistant text from `transcript_path`, then runs the same detector through the shared `_sdk` runtime. A missing or unreadable transcript is a finding, not an empty reply.
- `diu_limit.py` -- the word limit and what it does not count. The reminder's wording and the Stop hook's check both read it, so they cannot disagree; `tests/test_limit_agreement.py` pins that.
- `plain_words.py` -- turns every `phrases/` word list into one question about the user's last message and the finished reply, hands it to the background judge, and waits for the answer so a hit blocks the same turn. A verdict delivered on the next prompt is one the user may never see. No answer in time means the turn ends unblocked.
- `plain_words.py` -- turns every `phrases/` word list into one question about the user's last message and the finished reply, hands it to the background judge, and waits for the answer so a hit blocks the same turn. A verdict delivered on the next prompt is one the user may never see. No answer in time means the turn ends unblocked. The judge reads the last five human turns, but the words the user has written anywhere in the chat go along as an allow-list, and a hit whose every content word (plurals and tenses folded, stopwords dropped) the user has already used is dropped before it blocks: a word the user said six turns ago is still the user's word.
- `phrases/` -- the word lists themselves, one file per kind of wording to avoid, in the format `engine/hooks/llm-judge/phrases.py` loads.
- `install_claude_hook.py` -- merges both of the above into `~/.claude/settings.json`, idempotently, without touching anything else there.
- `cursor.hooks.json` -- the whole file to install as `~/.cursor/hooks.json`.
Expand Down
67 changes: 59 additions & 8 deletions engine/hooks/diu-stop/plain_words.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import json
import os
import re
import sys
import time
import uuid
Expand All @@ -26,6 +27,12 @@
TEXT_LIMIT = 4000
RECENT_USER_TURNS = 5
META_USER_PREFIXES = ("<command-", "<task-notification", "<system", "<local-command", "Stop hook feedback")
WORD_RE = re.compile(r"[a-z0-9][a-z0-9'/_.+-]*")
STOPWORDS = frozenset(
"a an the and or but of in on at to for with by from as is are was were be been it its this that these those "
"so if then than not no yes we i you he she they them our your my me us do does did done has have had can could "
"will would should may might into onto over under about after before again also just only all any each".split()
)
ANSWER_SHAPE = '{"match": true|false, "category": "<list name or empty>", "closest": "<the assistant words, or empty>"}'
MESSAGE = (
"diu: the last reply used wording the user has had to ask about ({category}): \"{closest}\". "
Expand Down Expand Up @@ -73,10 +80,10 @@ def _message_text(data: dict) -> str:
return ""


def recent_user_messages(path: str, limit: int = RECENT_USER_TURNS) -> str:
"""Join the last few human turns so the judge sees names the user already used."""
if not path or not os.path.isfile(path) or limit < 1:
return ""
def human_turns(path: str) -> list[str]:
"""Every human turn in the transcript, oldest first, with harness and hook rows left out."""
if not path or not os.path.isfile(path):
return []
found: list[str] = []
with open(path, encoding="utf-8", errors="replace") as handle:
for line in handle:
Expand All @@ -91,17 +98,53 @@ def recent_user_messages(path: str, limit: int = RECENT_USER_TURNS) -> str:
text = _message_text(data).strip()
if text and not text.startswith(META_USER_PREFIXES):
found.append(text)
return found


def recent_user_messages(path: str, limit: int = RECENT_USER_TURNS) -> str:
"""Join the last few human turns so the judge sees names the user already used."""
if limit < 1:
return ""
found = human_turns(path)
if not found:
return ""
return "\n\n".join(found[-limit:])


def _stem(word: str) -> str:
"""Fold plurals and tenses so "MRs" and "MR", "force-pushed" and "force-push" compare equal."""
word = word.strip("'.-/")
for ending, shortest_stem in (("ing", 3), ("ed", 3), ("s", 2)):
if word.endswith(ending) and len(word) - len(ending) >= shortest_stem and not word.endswith("ss"):
return word[: -len(ending)]
return word


def content_words(text: str) -> set[str]:
"""Lower-cased, lightly stemmed words with the stopwords dropped."""
return {_stem(word) for word in WORD_RE.findall((text or "").lower()) if word not in STOPWORDS}


def user_vocabulary(path: str) -> set[str]:
"""Content words from every human turn, not only the recent ones the judge reads."""
words: set[str] = set()
for text in human_turns(path):
words |= content_words(text)
return words


def used_by_user(phrase: str, vocabulary: set[str]) -> bool:
"""True when every content word of the flagged phrase is one the user has written."""
words = content_words(phrase)
return bool(words) and words <= set(vocabulary)


def last_user_message(path: str) -> str:
recent = recent_user_messages(path, limit=1)
return recent


def prompt(dictionaries: list[dict], asked: str, reply: str) -> str:
def prompt(dictionaries: list[dict], asked: str, reply: str, vocabulary: set[str] | None = None) -> str:
lines = [
f"Return exactly one line of JSON: {ANSWER_SHAPE}",
"Each list below names a kind of wording to avoid when writing to this user.",
Expand All @@ -121,6 +164,9 @@ def prompt(dictionaries: list[dict], asked: str, reply: str) -> str:
"or uses a specialized word that is not in the USER text and is not defined in the same ASSISTANT sentence.",
"Prefer category plain-words-vocabulary-drift for inventing or rotating names without a definition.",
"",
"WORDS THE USER HAS ALREADY USED, in any form, anywhere in this chat (never count these): "
+ ", ".join(sorted(vocabulary or ()))[:TEXT_LIMIT],
"",
f"USER:\n{asked[-TEXT_LIMIT:]}",
"",
f"ASSISTANT:\n{reply[-TEXT_LIMIT:]}",
Expand All @@ -138,23 +184,27 @@ def job(payload: dict) -> dict | None:
dictionaries = [phrases.load(name, directory=PHRASES_DIR) for name in list_names()]
if not dictionaries:
return None
vocabulary = user_vocabulary(transcript)
return {
"id": uuid.uuid4().hex,
"hook": HOOK_NAME,
"transcript": transcript,
"prompt": prompt(dictionaries, recent_user_messages(transcript), reply),
"prompt": prompt(dictionaries, recent_user_messages(transcript), reply, vocabulary),
"user_vocabulary": sorted(vocabulary),
"hit_if_all_true": ["match"],
"on_hit": "diu: the last reply used wording the user has had to ask about; say it in everyday words.",
}


def message_for(verdict: dict, reply: str) -> str:
def message_for(verdict: dict, reply: str, vocabulary: set[str] | None = None) -> str:
if verdict.get("outcome") != "hit":
return ""
answer = verdict.get("answer") or {}
closest = str(answer.get("closest") or "").strip()
if not closest or closest not in reply:
return ""
if vocabulary and used_by_user(closest, vocabulary):
return ""
return MESSAGE.format(category=str(answer.get("category") or "unnamed list"), closest=closest)


Expand All @@ -181,10 +231,11 @@ def check_reply(payload: dict) -> str:
return ""
deadline = time.monotonic() + wait_seconds()
transcript = built["transcript"]
vocabulary = set(built.get("user_vocabulary") or ())
while time.monotonic() < deadline:
for verdict in judge.drain(transcript):
if verdict.get("id") == built["id"]:
return message_for(verdict, payload.get("last_assistant_message") or "")
return message_for(verdict, payload.get("last_assistant_message") or "", vocabulary)
time.sleep(POLL_SECONDS)
return ""

Expand Down
59 changes: 59 additions & 0 deletions engine/hooks/diu-stop/tests/test_plain_words_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,66 @@ def test_a_clean_or_unchecked_verdict_says_nothing(self):
self.assertEqual(plain_words.message_for({"id": "j1", "outcome": outcome}, REPLY), "")


def _long_ago_rows(first_user_text, reply):
rows = [_user(first_user_text), _assistant("ok")]
for turn in range(6):
rows.append(_user(f"next question {turn}"))
rows.append(_assistant(f"answer {turn}"))
rows.append(_assistant(reply))
return rows


class TestUserVocabulary(PlainWordsCase):
def test_user_vocabulary_reads_every_human_turn_not_just_the_last_five(self):
rows = _long_ago_rows("lets review the MRs in the database", "The MR database has 3 rows.")
path = _transcript(self.tmp, rows)
vocabulary = plain_words.user_vocabulary(path)
self.assertIn("mr", vocabulary)
self.assertIn("database", vocabulary)
self.assertNotIn("the", vocabulary)
self.assertNotIn("MRs", plain_words.recent_user_messages(path))

def test_used_by_user_matches_plural_and_tense_forms(self):
vocabulary = plain_words.user_vocabulary(_transcript(self.tmp, [_user("force-push with lease, then look at the MRs")]))
self.assertTrue(plain_words.used_by_user("MR database", vocabulary | {"database"}))
self.assertTrue(plain_words.used_by_user("force-pushed with lease", vocabulary))
self.assertFalse(plain_words.used_by_user("thrash vector", vocabulary))
self.assertFalse(plain_words.used_by_user("", vocabulary))

def test_the_job_prompt_lists_the_users_words_as_an_allow_list(self):
rows = _long_ago_rows("lets review the MRs in the database", "The MR database has 3 rows.")
built = plain_words.job(self.payload(rows=rows, last_assistant_message="The MR database has 3 rows."))
self.assertIn("WORDS THE USER HAS ALREADY USED", built["prompt"])
self.assertIn("mr", built["user_vocabulary"])


class TestCheckReply(PlainWordsCase):
def test_plain_words_skips_a_term_the_user_used_six_turns_ago(self):
reply = "The MR database has 3 rows."
rows = _long_ago_rows("lets review the MRs in the database", reply)
payload = self.payload(rows=rows, last_assistant_message=reply)
with patch.object(self.judge, "enqueue", side_effect=lambda job: job["id"]):
with patch.object(self.judge, "drain", side_effect=lambda transcript: [_hit(self._queued_id, "MR database")]):
with patch.object(plain_words, "job", side_effect=self._remember(plain_words.job)):
self.assertEqual(plain_words.check_reply(payload), "")

def _remember(self, real_job):
def wrapped(payload):
built = real_job(payload)
self._queued_id = built["id"]
return built
return wrapped

def test_a_term_the_user_never_used_still_blocks(self):
reply = "The MR database has 3 rows."
rows = _long_ago_rows("lets review the database", reply)
payload = self.payload(rows=rows, last_assistant_message=reply)
with patch.object(self.judge, "enqueue", side_effect=lambda job: job["id"]):
with patch.object(self.judge, "drain", side_effect=lambda transcript: [_hit(self._queued_id, "MR database")]):
with patch.object(plain_words, "job", side_effect=self._remember(plain_words.job)):
text = plain_words.check_reply(payload)
self.assertIn("MR database", text)

def test_the_hit_comes_back_in_the_same_turn(self):
payload = self.payload()
built = {
Expand Down
8 changes: 6 additions & 2 deletions engine/hooks/hedge-runs-prove-it/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ past-tense narration of an old incident ("the task was a zombie").

An error message can expose a local fallback or hardcoded enumeration without
proving the capabilities of the system named in that error. The hook records
values in bracketed or comma-joined lists from error-shaped tool results. It
blocks an outgoing reply that repeats at least two of those values within 200
values in bracketed or comma-joined lists from error-shaped tool results, and
only when the error text itself introduces the list ("Known models:", "valid
values:", "one of") and only from this turn's tool results. Only
identifier-shaped values count (a letter plus an inner `-_./:@+`, like
`gpt-5.5`); plain words, numbers, and `p95` between commas in an error log are
prose, not a capability list. It blocks an outgoing reply that repeats at least two of those values within 200
characters of `accepts`, `supports`, `known models`, `valid`, or `only` when
those values appeared nowhere in a non-error tool result.

Expand Down
75 changes: 62 additions & 13 deletions engine/hooks/hedge-runs-prove-it/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
what another system accepts or supports. A reply that repeats two or more of
those values beside a capability verb is blocked unless a non-error tool result
also supplied them, or the reply attributes the list as fallback/error data.
Only a list the error output itself introduces ("Known models: [a-b, c-d]",
"valid values: ...") counts, only from this turn's tool results, and only
identifier-shaped values (a letter plus an inner `-_./:@+`): ordinary words
and numbers that merely sit between commas in an error log are not a list of
what a system supports.

Hedges about things that are not code or state (a company's motive, a
filing date) are out of scope, and so is either shape quoted rather than
Expand Down Expand Up @@ -132,13 +137,19 @@
r"\bnot\s+supported\b",
re.IGNORECASE,
)
BRACKETED_ENUM_RE = re.compile(r"\[([^\[\]\n]+)\]")
COMMA_ENUM_RE = re.compile(
r"(?<![A-Za-z0-9_.+/@:-])"
r"([A-Za-z0-9][A-Za-z0-9_.+/@:-]*(?:\s*,\s*[A-Za-z0-9][A-Za-z0-9_.+/@:-]*)+)"
r"(?![A-Za-z0-9_.+/@:-])"
)
ENUM_VALUE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+/@:-]*$")
IDENTIFIER_SEPARATOR_RE = re.compile(r"[A-Za-z0-9][_.+/@:-][A-Za-z0-9]")
LIST_INTRO_RE = re.compile(
r"\b(?:known|supported|valid|available|allowed|accepted|expected|permitted)\b"
r"[^\n:\[]{0,40}[:\[]\s*"
r"|\bone\s+of\b\s*[:\[]?\s*",
re.IGNORECASE,
)
CAPABILITY_RE = re.compile(
r"\b(?:accepts?|supports?|known\s+models?|valid|only)\b",
re.IGNORECASE,
Expand Down Expand Up @@ -288,14 +299,36 @@ def _tool_results(lines: list[dict]):
yield block, _tool_result_text(block)


def _identifier_shaped(value: str) -> bool:
"""A letter plus an inner separator: `gpt-5.5`, `a/b`, `x_y`. Plain words,
bare numbers and `95th` are prose, not capability values."""
return (
len(value) >= 2
and bool(ENUM_VALUE_RE.fullmatch(value))
and any(ch.isalpha() for ch in value)
and bool(IDENTIFIER_SEPARATOR_RE.search(value))
)


def _enumerated_values(text: str) -> set[str]:
runs = [match.group(1) for match in BRACKETED_ENUM_RE.finditer(text or "")]
runs.extend(match.group(1) for match in COMMA_ENUM_RE.finditer(text or ""))
"""Identifier-shaped values in a list the text itself introduces.

The list is the bracketed or comma run that starts right after a label
such as "Known models:" or "one of", on the same line. A comma run with
no such label is prose or log text and yields nothing.
"""
values: set[str] = set()
for run in runs:
for intro in LIST_INTRO_RE.finditer(text or ""):
line_end = text.find("\n", intro.end())
tail = text[intro.end(): line_end if line_end != -1 else len(text)]
if intro.group(0).rstrip().endswith("[") or tail.startswith("["):
run = tail.lstrip("[").split("]", 1)[0]
else:
match = COMMA_ENUM_RE.match(tail)
run = match.group(1) if match else ""
for raw_value in run.split(","):
value = raw_value.strip().strip("'\"`")
if len(value) >= 2 and ENUM_VALUE_RE.fullmatch(value):
value = raw_value.strip().strip("'\"`").rstrip(".")
if _identifier_shaped(value):
values.add(value.lower())
return values

Expand All @@ -308,16 +341,32 @@ def _value_occurs(text: str, value: str) -> bool:
))


def _turn_start(lines: list[dict]) -> int:
start = 0
for i, data in enumerate(lines):
if _is_human_user_line(data):
start = i
return start


def error_only_capability_values(lines: list[dict]) -> set[str]:
"""Enumerated values whose tool-result sources are all error-shaped."""
"""Enumerated values whose tool-result sources are all error-shaped.

Error-shaped sources count only from the current turn, so an error log
from earlier in the session cannot seed a value. Non-error sources count
from the whole transcript, so any earlier clean result still clears it.
"""
turn_start = _turn_start(lines)
error_values: set[str] = set()
non_error_results: list[str] = []
for block, text in _tool_results(lines):
error_shaped = bool(block.get("is_error") or ERROR_OUTPUT_RE.search(text))
if error_shaped:
error_values.update(_enumerated_values(text))
else:
non_error_results.append(text)
for index, data in enumerate(lines):
for block, text in _tool_results([data]):
error_shaped = bool(block.get("is_error") or ERROR_OUTPUT_RE.search(text))
if error_shaped:
if index >= turn_start:
error_values.update(_enumerated_values(text))
else:
non_error_results.append(text)
return {
value for value in error_values
if not any(_value_occurs(text, value) for text in non_error_results)
Expand Down
Loading
Loading