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
2 changes: 1 addition & 1 deletion docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ again.
| `explicit-failures` | hook (advisory; always on) |
| `external-claim-gate` | hook (PreToolUse on `Bash`; blocks a gh issue/comment/release/api write whose body claims a cause or fix with no evidence; blocks as UNCHECKED when the body cannot be read) |
| `playbook-router` | hook (UserPromptSubmit; injects the steps of the one playbook a prompt names) |
| `diu-stop` | hook |
| `diu-stop` | hook (Stop; blocks a reply over the word limit or with an unproven claim, and asks the background judge whether the reply used wording from its `phrases/` lists, waiting for that answer so a hit blocks the same turn) |
| `frustration-watchdog` | hook |
| `named-verb-guard` | hook |
| `plan-discipline` | hook (not always installed) |
Expand Down
4 changes: 3 additions & 1 deletion engine/hooks/diu-stop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ power at that point:
## Files

- `claude.hook.json` -- the `Stop` hook `"hooks"` object to merge into `~/.claude/settings.json`.
- `claude_stop_check.py` -- the script that hook runs. No LLM, no machine-specific paths.
- `claude_stop_check.py` -- the script that hook runs. Its word count and claim checks use no model; it also asks the background judge about the `phrases/` word lists and waits for that answer, so a hit blocks the same turn. No machine-specific paths.
- `claude.prompt.hook.json` -- the `UserPromptSubmit` hook `"hooks"` object, merged the same way.
- `claude_prompt_reminder.py` -- the script that hook runs. No LLM, no per-turn conditional logic -- always emits the same short reminder.
- `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.
- `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`.
- `codex_notify.py` -- the script to point Codex's `notify` at. No machine-specific paths.
Expand Down
2 changes: 1 addition & 1 deletion engine/hooks/diu-stop/claude.hook.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py",
"timeout": 10
"timeout": 60
}
]
}
Expand Down
7 changes: 6 additions & 1 deletion engine/hooks/diu-stop/claude_stop_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import sys

from diu_limit import WORD_LIMIT, counted_words
from plain_words import try_check_reply

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_markers"))
Expand Down Expand Up @@ -181,15 +182,19 @@ def main():

message = data.get("last_assistant_message") or ""

plain_words_note = try_check_reply(data)

word_count = counted_words(message)
over_limit = word_count > WORD_LIMIT
claim = find_unverified_claim(message)
marker_problems = find_marker_problems(message)

if not over_limit and not claim and not marker_problems:
if not over_limit and not claim and not marker_problems and not plain_words_note:
return

parts = []
if plain_words_note:
parts.append(plain_words_note)
if claim:
parts.append(
f"This message makes an unverified-shaped claim (\"{claim}\") with no "
Expand Down
17 changes: 17 additions & 0 deletions engine/hooks/diu-stop/phrases/plain-words-code-names.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"checker": "plain-words-code-names",
"meaning": "The reply puts a raw code, config, or function name in front of the user as if it were a word, without saying in everyday words what it does.",
"reads": "exchange",
"match": [
"Set disable-model-invocation: true on the skill.",
"It flips desiredEnabled: true.",
"materialize_revision now calls realpath.",
"The check reads EXIT_CODES and PULL_REF_RE."
],
"not_match": [
"The skill is hidden from the automatic skill list, so only a typed command can start it.",
"The worker is switched on.",
"The replay tool now follows folder shortcuts before comparing paths (backtest_detector.py:102)."
],
"on_hit": "plain-words: the last reply named code or settings without saying what they do in everyday words."
}
18 changes: 18 additions & 0 deletions engine/hooks/diu-stop/phrases/plain-words-internal-names.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"checker": "plain-words-internal-names",
"meaning": "The reply names an internal tool, worker, queue, or process step as if the user already knows what it is, without saying what it does.",
"reads": "exchange",
"match": [
"It points at a real design gap in e2e-autofix.",
"Run the invoker-watcher first.",
"The backlog grew after the redeploy before it started shrinking.",
"Preflight passes and the review unit is engine-runtime.",
"The merge gate is review_ready."
],
"not_match": [
"The worker that retries failed browser tests is making many copies of one job.",
"The repo's pre-publish check passes.",
"You asked about the merge queue: it is the line of PRs waiting for checks before they merge."
],
"on_hit": "plain-words: the last reply named an internal tool or step without saying what it does."
}
18 changes: 18 additions & 0 deletions engine/hooks/diu-stop/phrases/plain-words-made-up-labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"checker": "plain-words-made-up-labels",
"meaning": "The reply uses a label the assistant made up while working, which the user has not used, and does not say in everyday words what it means.",
"reads": "exchange",
"match": [
"No hook decides differently.",
"The hooks only gain replay functions.",
"Is this the right safety line?",
"That was the fake problem.",
"Each slice passes on its own."
],
"not_match": [
"The hook's logic does not change; this is a simple refactor.",
"Each hook gets a small extra function that replays old chats; the live hook never calls it.",
"You asked about the safety line: it is one sentence saying why the change cannot break anything."
],
"on_hit": "plain-words: the last reply used a made-up label; say what it means in everyday words or drop it."
}
18 changes: 18 additions & 0 deletions engine/hooks/diu-stop/phrases/plain-words-status-words.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"checker": "plain-words-status-words",
"meaning": "The reply uses a status word or state label without saying what actually happened or why.",
"reads": "exchange",
"match": [
"Three tests were capped.",
"The reset is blocked.",
"That would double count.",
"Only a restart picks up newer code.",
"The branch is stale."
],
"not_match": [
"Three tests hit the retry limit, so they stopped and now need a person.",
"The restart could not run because a check refused it.",
"The running program still uses the version it loaded earlier; a restart loads the current version."
],
"on_hit": "plain-words: the last reply used a status word without saying what happened."
}
18 changes: 18 additions & 0 deletions engine/hooks/diu-stop/phrases/plain-words-tech-jargon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"checker": "plain-words-tech-jargon",
"meaning": "The reply uses technical jargon or an abbreviation the user has not used, without a plain explanation next to it.",
"reads": "exchange",
"match": [
"It died on a 401 unauthorized.",
"That is a remote infra gap.",
"The script ships as an SEA.",
"Mostly regex false positives.",
"Force-push with lease, then a three-way apply."
],
"not_match": [
"The login had expired, so the server refused the request.",
"The problem is on the other machines, not this laptop.",
"Most of the alarms were wrong: the word matched but the meaning did not."
],
"on_hit": "plain-words: the last reply used jargon or an abbreviation without a plain explanation."
}
184 changes: 184 additions & 0 deletions engine/hooks/diu-stop/plain_words.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Ask the background judge whether the last reply used wording the user has
to ask about, and wait for the answer.

The word lists live in phrases/, one file per kind of wording. They become one
question, so one model call covers every kind and names the one it found. The
Stop hook waits for that answer and shows it in the same turn, because a
verdict that waits for the user's next message is a verdict the user may never
see. When no answer arrives in time, the turn ends unblocked.
"""
from __future__ import annotations

import json
import os
import sys
import time
import uuid

HOOK_DIR = os.path.dirname(os.path.abspath(__file__))
LLM_JUDGE_DIR = os.path.join(os.path.dirname(HOOK_DIR), "llm-judge")
PHRASES_DIR = os.path.join(HOOK_DIR, "phrases")
PREFIX = "plain-words-"
WAIT_ENV = "DIU_PLAIN_WORDS_WAIT_SECONDS"
DEFAULT_WAIT_SECONDS = 40.0
POLL_SECONDS = 0.5
HOOK_NAME = "diu-plain-words"
TEXT_LIMIT = 4000
META_USER_PREFIXES = ("<command-", "<task-notification", "<system", "<local-command", "Stop hook feedback")
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}\". "
"Say it in everyday words, or explain the term in the same sentence."
)


def _llm_judge():
if LLM_JUDGE_DIR not in sys.path:
sys.path.insert(0, LLM_JUDGE_DIR)
import judge
import phrases

return judge, phrases


def list_names(directory: str = PHRASES_DIR) -> list[str]:
try:
names = os.listdir(directory)
except OSError as exc:
raise ValueError(f"{directory}: word lists could not be listed: {exc}") from exc
return sorted(name[: -len(".json")] for name in names if name.startswith(PREFIX) and name.endswith(".json"))


def _is_user_line(data: dict) -> bool:
if data.get("type") == "user":
return True
message = data.get("message")
return isinstance(message, dict) and message.get("role") == "user"


def _message_text(data: dict) -> str:
message = data.get("message")
content = message.get("content") if isinstance(message, dict) else data.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text") or "")
elif isinstance(block, str):
parts.append(block)
return "\n".join(parts)
return ""


def last_user_message(path: str) -> str:
if not path or not os.path.isfile(path):
return ""
found = ""
with open(path, encoding="utf-8", errors="replace") as handle:
for line in handle:
try:
data = json.loads(line)
except ValueError:
continue
if not isinstance(data, dict) or data.get("isSidechain") or data.get("isMeta"):
continue
if not _is_user_line(data):
continue
text = _message_text(data).strip()
if text and not text.startswith(META_USER_PREFIXES):
found = text
return found


def prompt(dictionaries: list[dict], asked: str, reply: str) -> 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.",
]
for dictionary in dictionaries:
lines.append("")
lines.append(f"List {dictionary['checker']}: {dictionary['meaning']}")
lines.append(f"Examples that match: {json.dumps(dictionary['match'], ensure_ascii=False)}")
lines.append(f"Examples that do not match: {json.dumps(dictionary['not_match'], ensure_ascii=False)}")
lines.extend(
[
"",
"Set match to true only when the ASSISTANT text below uses such wording.",
"closest must be copied word for word from the ASSISTANT text, never from the lists.",
"A word the USER used first does not count. A word that is only quoted, negated, or described does not count.",
"",
f"USER:\n{asked[-TEXT_LIMIT:]}",
"",
f"ASSISTANT:\n{reply[-TEXT_LIMIT:]}",
]
)
return "\n".join(lines)


def job(payload: dict) -> dict | None:
reply = payload.get("last_assistant_message") or ""
transcript = payload.get("transcript_path") or ""
if not reply.strip() or not transcript or not os.path.isfile(transcript):
return None
_, phrases = _llm_judge()
dictionaries = [phrases.load(name, directory=PHRASES_DIR) for name in list_names()]
if not dictionaries:
return None
return {
"id": uuid.uuid4().hex,
"hook": HOOK_NAME,
"transcript": transcript,
"prompt": prompt(dictionaries, last_user_message(transcript), reply),
"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:
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 ""
return MESSAGE.format(category=str(answer.get("category") or "unnamed list"), closest=closest)


def wait_seconds() -> float:
raw = os.environ.get(WAIT_ENV)
if raw is None:
return DEFAULT_WAIT_SECONDS
try:
return max(0.0, float(raw))
except ValueError:
return DEFAULT_WAIT_SECONDS


def check_reply(payload: dict) -> str:
if not isinstance(payload, dict) or payload.get("agent_id") or payload.get("stop_hook_active"):
return ""
built = job(payload)
if built is None:
return ""
judge, _ = _llm_judge()
if judge.enqueue(built) is None:
return ""
deadline = time.monotonic() + wait_seconds()
transcript = built["transcript"]
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 "")
time.sleep(POLL_SECONDS)
return ""


def try_check_reply(payload: dict) -> str:
"""check_reply for the hook scripts: an error is logged, never raised."""
try:
return check_reply(payload)
except Exception as exc:
sys.stderr.write(f"diu-stop: plain-words check failed: {type(exc).__name__}: {exc}\n")
return ""
Loading
Loading