diff --git a/.claude/hooks/language_check.py b/.claude/hooks/language_check.py new file mode 100644 index 00000000..a32491ba --- /dev/null +++ b/.claude/hooks/language_check.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Stop hook: does the final message contain vocabulary the user never reads? + +**Log-only for now. It blocks nothing.** The patterns it carries were measured +against saved build screens, not live messages, and every checker written for +this framework so far has needed two to five corrections before it stopped +misfiring. Doing that in front of a user is not acceptable, so the first +deployment records what it *would* have blocked and lets the turn through. + +To enforce, set "enforce": true in .claude/language.json. The hook then exits 2 +with the offending term, which feeds back to the model as a reason to restate -- +it cannot rewrite the message silently, only ask for another. + +Known false positive, unfixed on purpose: the rules govern volunteered +narration, and CONTEXT.md is explicit that "none of this governs what you say +when asked directly." A user who asks what the gate is should get an answer +containing the word. The log will show how often that happens before anything +starts blocking on it. +""" +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CATALOG = ROOT / ".claude" / "language.json" +LOG = ROOT / ".claude" / "language-violations.jsonl" +BEAT = ROOT / ".claude" / "language-check-ran" + + +# The evaluation harness interviews a finished build about how it went. Those +# answers are addressed to the harness, not to the user, and discuss the +# framework's own mechanics by design -- the first violation this hook ever +# logged was a debrief answer naming `interview` and `preflight`, which is +# correct English for what it was doing. Enforcing here would have blocked every +# debrief and read as the model failing to comply. +# +# The harness writes this marker into the session before it asks anything, so +# the boundary is already in the transcript. Real users never see it. +DEBRIEF_MARKER = "DEBRIEF-BEGIN-8f2c1a" + + +def in_debrief(transcript: Path) -> bool: + """Has the harness started interviewing this session? + + The marker has to be found in something the *user* said. It used to be a + substring test over the whole file, and this file is tracked -- so it ships + into every build's clone, and a session that read its own hook would put the + marker in a tool result and exempt itself from every check for the rest of + the run, silently and with a clean log. No build has done it (0 of 61 read + .claude/hooks at all), which is why it was still here to find rather than + something already lost. + + The harness sends the debrief as user input. Reading a file does not. + """ + try: + lines = transcript.read_text(encoding="utf-8", + errors="replace").splitlines() + except OSError: + return False + for line in lines: + line = line.strip() + if not line or DEBRIEF_MARKER not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "user": + continue + content = (event.get("message") or {}).get("content") + if isinstance(content, str): + if DEBRIEF_MARKER in content: + return True + elif isinstance(content, list): + for b in content: + # A tool result is carried inside a user event too, and that is + # exactly the path a file read takes. Only spoken text counts. + if isinstance(b, dict) and b.get("type") == "text" and DEBRIEF_MARKER in (b.get("text") or ""): + return True + return False + + +def last_assistant_text(transcript: Path) -> str: + """The final assistant message, which is what the user just read.""" + said: list = [] + for line in transcript.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + kind = event.get("type") + content = (event.get("message") or {}).get("content") + + if kind == "user": + spoke = False + if isinstance(content, str) and content.strip(): + spoke = True + elif isinstance(content, list): + spoke = any(isinstance(b, dict) and b.get("type") == "text" + and (b.get("text") or "").strip() for b in content) + if spoke: + said = [] # a new turn begins; the rest is read + continue + + if kind != "assistant": + continue + if isinstance(content, str): + if content.strip(): + said.append(content) + elif isinstance(content, list): + parts = [b.get("text", "") for b in content + if isinstance(b, dict) and b.get("type") == "text"] + if any(p.strip() for p in parts): + said.append("\n".join(parts)) + # Asking IS the boundary. In a driven build the answer to an + # AskUserQuestion comes back as a tool result, not as typed text, + # so resetting only on a typed message left the whole build as one + # unbroken turn: 0907-0518-t3q reported the same two sentences five + # times, once per later question. What the reader has in front of + # them when a question appears is what was said since the previous + # question -- so the last one recorded ends the stretch. + if isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "tool_use" + and b.get("name") == "AskUserQuestion" for b in content): + said = [] + return "\n".join(said) + + +def beat(event: str = "?") -> None: + """Record that the hook ran, before anything can return early. + + This is proof of invocation, so it must not sit behind a check that can + skip it. Placed after the transcript checks it recorded one turn out of + eight -- the other seven returned early and looked identical to the hook + never having run, which is the exact ambiguity it exists to remove. That + was diagnosed once and left in place; this is the correction. + """ + try: + n = 0 + if BEAT.exists(): + n = int(BEAT.read_text(encoding="utf-8").split()[0] or 0) + BEAT.write_text(f"{n + 1} turns checked ({event}), last " + f"{datetime.now().isoformat(timespec='seconds')}\n", + encoding="utf-8") + except Exception: + pass + + +def main() -> int: + # Read first, then beat -- the beat records which event fired, and a + # payload that fails to parse still gets a beat below. + try: + payload = json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError: + beat("unparseable") + return 0 + beat(payload.get("hook_event_name") or "?") + + transcript = payload.get("transcript_path") + if not transcript or not Path(transcript).exists() or not CATALOG.exists(): + return 0 + + try: + catalog = json.loads(CATALOG.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return 0 + if in_debrief(Path(transcript)): + return 0 # answers to the harness, not to the user + + said = last_assistant_text(Path(transcript)) + + # On `Stop` the turn ended in prose and that prose is the whole of what + # the user read. On `PreToolUse`/`AskUserQuestion` the turn ends in a + # question, and the question -- its header, its option labels, their + # descriptions -- is read just as closely as the narration above it. A + # `Stop` hook never sees either half: measured over the last twelve + # builds, every one of the 34 vocabulary and setup findings sat on a + # screen that ended in a question or in setup, and none on a screen the + # `Stop` hook could reach. + if payload.get("tool_name") == "AskUserQuestion": + parts = [said] + for q in (payload.get("tool_input") or {}).get("questions") or []: + parts.append(str(q.get("question") or "")) + parts.append(str(q.get("header") or "")) + for opt in q.get("options") or []: + parts.append(str(opt.get("label") or "")) + parts.append(str(opt.get("description") or "")) + said = chr(10).join(x for x in parts if x) + + if not said.strip(): + return 0 + + hits = [] + for flags, key in ((re.I, "case_insensitive"), (0, "case_sensitive")): + for pattern, instead in catalog.get(key, {}).items(): + try: + found = list(re.finditer(pattern, said, flags)) + except re.error: + continue # one bad pattern must not silence the rest + for m in found: + hits.append({"term": m.group(0), "instead": instead}) + + if not hits: + return 0 + + try: + with LOG.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({ + "at": datetime.now().isoformat(timespec="seconds"), + "session": payload.get("session_id", ""), + "event": payload.get("hook_event_name", ""), + "tool": payload.get("tool_name", ""), + "hits": hits, + "said": said[:400], + }, ensure_ascii=False) + "\n") + except Exception: + pass + + if not catalog.get("enforce"): + return 0 + + terms = ", ".join(sorted({h["term"] for h in hits})) + instead = "; ".join(sorted({h["instead"] for h in hits})) + print(f"That turn used vocabulary the user never reads: {terms}. " + f"Say instead: {instead}. Restate it in their terms.", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + # A hook that raises fires on every turn. This one guards the session; it + # must not be able to damage it. A bad regex, an unreadable catalog, a + # transcript in an unexpected shape -- none of those are reasons to + # interrupt the user, so anything unhandled means "allow" rather than + # "crash". The only non-zero exit this file may produce is the deliberate + # 2 from an enforced violation. + try: + sys.exit(main()) + except SystemExit: + raise + except BaseException: + sys.exit(0) diff --git a/.claude/language.json b/.claude/language.json new file mode 100644 index 00000000..02386351 --- /dev/null +++ b/.claude/language.json @@ -0,0 +1,29 @@ +{ + "_comment": [ + "The vocabulary a user never reads. One source of truth: the Stop hook", + "in .claude/hooks/language_check.py enforces it.", + "Each entry maps a pattern to what the user should read instead." + ], + "case_insensitive": { + "\\bharvest(?:ed|ing)?\\b": "your notebook already tested this -- I'll reuse its numbers", + "\\bcell class(?:es)?\\b": "what each step does", + "\\bdemo shortcut\\b": "quote the line: `df.head(5)`, cell 9", + "\\bgolden value(?:s)?\\b": "checked it still gives your notebook's answers", + "\\bthe probe\\b": "measuring what each setting does", + "\\bmasking\\b|\\bmasked by\\b": "these two interact", + "\\bfilter_defaults\\b|\\blink identifier\\b": "which spectrum the plot opens on", + "\\bthe gate\\b|\\bgate (?:passed|failed|found)\\b": "(say the finding, not the check)", + "\\bscreenshot\\b": "(say nothing -- open their browser)", + "\\bWorkflowManager\\b|\\bpython tool\\b|\\bstage skill\\b": "(internal -- name the page or the analysis)", + "\\bAppTest\\b|\\biframe\\b|\\bInsight component\\b": "the panel", + "\\bdesign round\\b|\\binterview\\b|\\bpersona\\b": "(internal -- just ask)", + "\\bmode=auto\\b|\\bprior evidence\\b|\\bsuggestion axis\\b": "(internal)", + "\\bmateriality threshold\\b|\\beffect size\\b": "how much it moves the result", + "\\bpreflight\\b": "(say nothing, or: setting up -- a minute)", + "\\bbrowser (?:control|setup|side|probe)\\b|\\bdriving the browser\\b": "(internal -- never narrate the browser)" + }, + "case_sensitive": { + "\\bHARDCODE\\b|\\bADVANCED\\b|\\bDASHBOARD CONTROL\\b": "keep it / hide it / put it on the results page", + "\\bDEFAULTS\\b|\\bOUTPUTS\\b": "the settings / what it produces" + } +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..a66f50bd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,38 @@ +{ + "_comment": [ + "The Stop hook checks what the last turn said. See", + ".claude/hooks/language_check.py; the vocabulary lives in", + ".claude/language.json.", + "The command probes for an interpreter rather than naming one. Bare", + "`python` on a default Windows box is the Microsoft Store stub, which", + "prints a notice and EXITS 0 -- so the hook silently never ran and its", + "empty log looked exactly like a clean session. An exit-code chain", + "cannot catch that, so each candidate is tested by whether it prints.", + "The project venv comes first because on such a machine it is the only", + "real interpreter present.", + "PreToolUse/AskUserQuestion covers what Stop structurally cannot. A Stop hook fires only on a turn that ends in prose; in a build most turns end in a question, and all 34 findings measured across twelve builds sat on those screens. Same script, same catalog, same log." + ], + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "for p in \"$CLAUDE_PROJECT_DIR/.venv/Scripts/python.exe\" \"$CLAUDE_PROJECT_DIR/.venv/bin/python\" \"$HOME/.local/bin/python.exe\" \"$HOME/.local/bin/python\" py python3 python; do if [ \"$(\"$p\" -c 'print(1)' 2>/dev/null)\" = \"1\" ]; then \"$p\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/language_check.py\"; exit $?; fi; done; exit 0" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "for p in \"$CLAUDE_PROJECT_DIR/.venv/Scripts/python.exe\" \"$CLAUDE_PROJECT_DIR/.venv/bin/python\" \"$HOME/.local/bin/python.exe\" \"$HOME/.local/bin/python\" py python3 python; do if [ \"$(\"$p\" -c 'print(1)' 2>/dev/null)\" = \"1\" ]; then \"$p\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/language_check.py\"; exit $?; fi; done; exit 0" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/add-presets.md b/.claude/skills/add-presets/SKILL.md similarity index 53% rename from .claude/skills/add-presets.md rename to .claude/skills/add-presets/SKILL.md index 1260a0c6..64f8c5db 100644 --- a/.claude/skills/add-presets.md +++ b/.claude/skills/add-presets/SKILL.md @@ -1,3 +1,8 @@ +--- +name: add-presets +description: Use when adding named parameter presets to a TOPP workflow, or when users need one-click parameter sets such as 'High Sensitivity' or 'Fast'. +--- + # Add Parameter Presets Add or modify parameter presets for a TOPP workflow in `presets.json`. @@ -25,18 +30,37 @@ Add or modify parameter presets for a TOPP workflow in `presets.json`. "algorithm:section:param_name": value }, "_general": { - "custom-widget-key": value + "custom-widget-key": value, + "