Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5dd986b
feat: support flag parameters without valuesAllow flags to work with …
hjn0415a May 19, 2026
b0e682e
ci(docker): publish multi-arch (amd64 + arm64) images to GHCR
claude May 25, 2026
d32c1b9
ci(docker): extend apptainer/nginx/traefik tests to cover arm64
claude May 25, 2026
1d73b67
fix(arm): use two-pass cmake so TOPP links against system libstdc++
claude May 25, 2026
f11bc99
fix(arm): install cmake via apt so pass-1 cmake is on plain bash PATH
claude May 25, 2026
5185c3e
fix(arm): call mamba cmake by full path in pass 1 (apt 3.22 is too old)
claude May 25, 2026
0bab3ae
ci: free disk space at the start of each integration test job
claude May 25, 2026
4790d46
fix(arm): keep CMakeFiles/ between make TOPP and make pyopenms
claude May 25, 2026
c7fdf00
ci(apptainer): drop arm64 — no upstream aarch64 .deb
claude May 25, 2026
f466229
fix(ci): keep /opt/hostedtoolcache when freeing disk space
claude May 26, 2026
f0d1db1
ci: dump cluster state on test-nginx/test-traefik failure
claude May 26, 2026
7aadb06
fix(ci): load images into kind via image-archive, drop docker load
claude May 26, 2026
6119021
ci: re-trigger workflow run
claude May 26, 2026
88dcdb0
ci: re-trigger workflow run after outage
claude May 26, 2026
39d6d25
fix(ci): match kind image to what the kustomize overlay references
claude May 26, 2026
ba05e6a
Merge pull request #393 from OpenMS/claude/gallant-mendel-K2jDd
t0mdavid-m May 27, 2026
0ad89fa
fix(ci): disable provenance on build-amd64 to keep pushes single-mani…
claude May 27, 2026
6aac16b
Merge pull request #394 from OpenMS/claude/fix-amd64-provenance
t0mdavid-m May 27, 2026
e9d162a
Merge pull request #391 from hjn0415a/feature/flag-parameter
t0mdavid-m Jun 11, 2026
a703e05
Merge pull request #396 from OpenMS/claude/laughing-fermat-ppiw0s
t0mdavid-m Jun 17, 2026
76a793e
fix treafik ingress
t0mdavid-m Jun 17, 2026
2b8cda2
Fix tool_instance_name resolution in save_parameters and support mult…
Copilot Jul 14, 2026
0f02ecf
InputTOPP: add reactive option and split impl/fragment for conditiona…
JKVISION1101 Jul 16, 2026
ad9bdb2
Add flag parameter support for TOPP tools (#397)
hjn0415a Jul 16, 2026
3e42f00
Pin cython below 3.3 so the full image builds again (#400)
t0mdavid-m Aug 28, 2026
e2c2a83
Let the app run on more than one node (#399)
t0mdavid-m Aug 28, 2026
d4088e5
fix(workflow): stop losing widget input on Streamlit < 1.50 (#402)
t0mdavid-m Aug 31, 2026
a9a3c7a
Add the notebook-to-webapp skill framework (#403)
t0mdavid-m Sep 7, 2026
2e864db
Make main's lint and doc-link checks pass again; drop eval/ reference…
t0mdavid-m Oct 11, 2026
3916a5b
Windows: get OpenMS from a reusable workflow instead of a contrib bui…
timosachsenberg Oct 13, 2026
5566fb0
Merge OpenMS/streamlit-template main into FLASHApp
claude Oct 16, 2026
8fc51e6
Keep FLASHApp's Kubernetes deployment doc
claude Oct 16, 2026
dd4a3d9
Use pl.len() for the row count in compression
claude Oct 16, 2026
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
246 changes: 246 additions & 0 deletions .claude/hooks/language_check.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions .claude/language.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
38 changes: 38 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
Loading
Loading