Sno Station
Sno Station은 AI 에이전트를 하나의 팀으로 묶는 오픈소스 워크스테이션으로, 로컬 머신에서 에이전트 협업을 가능하게 함
Claude·Code·Codex·Grok용 정지 훅, 미완성 작업 감시 및 CI 실행 방지
A Stop hook for Claude Code, Codex and Grok Build, for the turn that ends half-done — nothing done, an announced action never carried out, a promise to watch CI with nothing running — or in the wrong
플러그인Shell · PythonGitHub @Nanako0129직접 설치운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
A Stop hook for Claude Code, Codex and Grok Build, for the turn that ends half-done — nothing done, an announced action never carried out, a promise to watch CI with nothing running — or in the wrong language. Every judgement is made by TypeSafe Jev.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A Claude Code Stop hook for turns that stop half-done. It catches three premature exit patterns — stopping without taking action, declaring an action without calling the tools to back it up, and claiming to monitor background work while nothing is running — and intercepts the exit with a nudge so the turn does not close on an empty promise.README 전문 보기 ↗English | 繁體中文
A Claude Code
Stophook for turns that stop half-done. It catches three premature exit patterns — stopping without taking action, declaring an action without calling the tools to back it up, and claiming to monitor background work while nothing is running — and intercepts the exit with a nudge so the turn does not close on an empty promise.
Stingrays sit motionless on sand until stepped on. This hook operates the same way: silent during normal operation, intervening only when a turn stops prematurely. Every judgement goes to Jev: whether the turn did nothing, broke a promise, promised to watch something, or answered in the wrong language. The one thing counted locally is what is running in the background.
you: fix the timeout and run the suite
claude: I'll change the config value now, then run the suite.
[turn ends. nothing was changed. nothing was run.]
── stingray ──────────────────────────────────────────
this turn looks like it stopped half-done
(it declared an action that this turn's tool calls do not account for)
claude: [edits the config, runs the suite]
| # | Shape | Judged by |
|---|---|---|
| 1 | no_action — stopped without doing anything, when it could have acted | Jev |
| 2 | broken_promise — declared an action that this turn's tool calls do not account for | Jev |
| 3 | unwatched — promised to monitor external progress (CI, build, PR review) | Jev, against a local count (see below) |
| 4 | wrong_language — the final message is not in the language your settings ask for | Jev |
Shape 3 asks Jev whether the final message promises to watch an external result, or says a watch it set up is running (watch_claim), and acts on the answer against a count it makes locally:
session_crons, plus handoffs to another Claude session still waiting for an answer, equals 0. A promise with nothing behind it blocks on STINGRAY_SHAPE3=1.watch_mismatch, in the same request), and it blocks only on STINGRAY_SHAPE3_JUDGE=1.A handoff is a SendMessage to another Claude session (its result says so) with no <cross-session-message> from that session after it in the transcript. "Handed to the Windows session, waiting for it to report" blocked a real turn that had done exactly that, because neither list in the Stop payload shows a message waiting for its answer. A message to an in-process subagent is not counted; a running subagent is already background work.
The count is exact; the promise is a judgement. It was a set of regular expressions until 0.3.0, and those read topic rather than commitment: a reply that merely quoted the phrase "keep an eye on" blocked itself, while "I'll keep monitoring the build" and any promise in Japanese or Korean passed unseen. On the 50 labelled lines of tests/watch-fixture.tsv — including every sentence that once blocked a real turn by mistake, or a synthetic stand-in where the original was private — the promises score 0.68 and above, the rest 0.20 and below, and "keep an eye on the review" sits at the threshold.
The nudge delivered on interception is a fixed paragraph written to stderr, not a dynamically generated critique. It offers three ways out: finish the work, launch a background poll, or state what decision is blocking progress.
Shape 4 reads language from Claude Code's settings — the project's .claude/settings.local.json, then its .claude/settings.json, then your user settings.json under CLAUDE_CONFIG_DIR — and asks Jev whether the prose of the final message is written in it. Any target language works; common codes such as zh-TW are sent as a name, 繁體中文(台灣,zh-TW), which Jev reads far more reliably than the bare code. Its nudge asks for the same message again in that language, with code and identifiers left as they were.
A message is asked about only when it has prose to judge — at least 12 units once code, inline code, URLs, block quotes and paths are removed, a unit being one Han, kana or Hangul character or one word in any other script. Without that floor, a line of test results was judged "not Chinese", which is true and useless.
It needs an API key, like shapes 1 and 2, and sends the same redacted final message they do. It replaced a local rule that counted Han characters against English words, which could only ever see English: Japanese scored as Chinese, and Korean or Russian as nothing at all.
The language question sees language names replaced by a placeholder, 〔語言〕. Asked whether a reply is in 繁體中文, Jev read one that merely mentioned 簡體中文 as written in it: E(簡體中文,#142)已用 merge commit 合併。… blocked three turns in a row at 0.62–0.79. With the names replaced, that reply and its rewrite score 0.09–0.12 through the hook, and replies actually written in Simplified Chinese, English, Japanese and Korean still block at 0.64–0.97. Rewording the question's criterion instead left the false positives at 0.62–0.91 and dropped a Korean reply below τ. Shapes 1 and 2 get the message unchanged.
Every failure path exits 0: missing API keys, absent jq, missing questions.json, endpoints that are neither HTTPS nor loopback, credential patterns spotted in outgoing payloads, timeouts, non-200 responses, malformed bodies, scores outside [0, 1] or below threshold, unreadable background_tasks, or failures writing the interception counter.
In every failure mode, stingray leaves session behavior identical to an environment where the plugin was never installed. The hook can prompt additional work; it can never permit less. Every failure fails open by allowing the turn to conclude normally. Because the hook withholds an intervention rather than granting a permission, a defective configuration cannot approve work. An outage does mean the check did not run and the turn ended unexamined — the same position you are in without the plugin, which is why this direction is the safe one, but it is not the same as the turn having been checked.
Notice: Every command below is written for user scope — install once, use it in every project.
# install
claude plugin marketplace add Nanako0129/stingray
claude plugin install stingray@stingray --scope user
# update
claude plugin marketplace update stingray
claude plugin update stingray
# uninstall
claude plugin uninstall stingray
Tip: The in-session
/plugin installdialog asks you to pick a scope — choose User there.
Verification details: The manifest and installation commands were tested against a local checkout: the marketplace registered,
plugin installreported success, andplugin listshowedstingray@stingrayenabled at user scope. With no switches set, a payload that would otherwise trigger shape 3 exited 0 and created no state directory. TheNanako0129/stingraysyntax was verified the same way after merging: added from GitHub, installed at user scope, and confirmed to exit 0 without writing state files.
Installation alone performs no actions. The hook remains inactive until a switch is set. Set switches in the env block of ~/.claude/settings.json:
{
"env": {
"STINGRAY_SHAPE3": "1",
"STINGRAY_LANG": "1"
}
}
STINGRAY_SHAPE3 is the watch check and STINGRAY_LANG the language check. Both are judged by Jev, so both need an API key and send the redacted final message; there is no keyless mode. With an API key, start from "STINGRAY_SHADOW": "1" instead, which calls Jev and logs decision records but never blocks turn completion. Restart Claude Code after changing the file.
Why not
exportin your shell: Claude Code readssettings.jsonitself when it starts, so every session gets the switches however it was launched. A shellexportreaches only sessions started from a shell opened after the line was added. A terminal tab left open from before, the desktop app and IDE extensions all miss it, and the hook then does nothing without saying so. This was hit in practice: a tab open for eight days kept launching sessions with none of the switches set.
Verify that the plugin is loaded:
claude plugin list | grep stingray
tail -f ~/.local/state/stingray/decisions.jsonl # written by shape 3 too, before it blocks
Install the Codex hook directly from the GitHub marketplace:
codex plugin marketplace add Nanako0129/stingray
codex plugin add stingray@stingray
The Codex manifest at .codex-plugin/plugin.json explicitly selects
hooks/hooks.codex.json, so Codex does not load the root's Claude
hooks/hooks.json. The Claude and Codex manifests carry the same version;
CI checks that they stay in sync. A Git tag therefore contains that version
of both plugins without a separate ZIP. To update, run
codex plugin marketplace upgrade stingray, then
codex plugin add stingray@stingray.
Start a new Codex session after installing or updating. Review and trust the
bundled Stop hook using /hooks before enabling it: the hook runs shell
commands locally. Installation does not enable Stingray's checks by itself.
Set STINGRAY, STINGRAY_SHAPE3, or
STINGRAY_SHADOW in the Codex process environment, and provide
TYPESAFE_API_KEY or ~/.config/typesafe/api_key. In v0.3.0, shape 3 also
requires the key and sends the redacted final message to TypeSafe. A shell
export does not reach an already-running Codex desktop process. The Codex Stop
payload currently lacks some Claude fields: if background-task state is absent,
shape 3 treats it as unknown and does not block on that check.
Shapes 1 and 2 are judged without this turn's tool list under Codex: the payload
carries no prompt_id to slice the transcript with, so the hook sends "tool list
for this turn unavailable" rather than "no tools were called". Measured on 5
finished-work replies: none was blocked, but no_action rose from 0.06–0.15
with the tool list to 0.32–0.38 without it, against τ = 0.5. The 81.8% figure
does not cover Codex; start with STINGRAY_SHADOW=1 there.
grok plugin install Nanako0129/stingray --trust
Grok reads the repository as a single plugin, not a marketplace, so install it directly. If stingray is already installed for Claude Code, Grok picks up that copy through its Claude Code compatibility and no Grok install is needed.
.grok-plugin/plugin.json selects hooks/hooks.grok.json, so Grok does not
load Claude's hooks/hooks.json. Installing enables the plugin; start a new
Grok session afterwards, as a running one keeps the hooks it loaded. The Stop
hook maps Grok's payload onto hooks/stingray.sh and rebuilds the turn's tool
list from Grok's session log. A no-op UserPromptSubmit hook is registered only
so that Grok logs the turn-start record that list is cut from. Background tasks
reach stingray as their type only, never their command line, and
STINGRAY_QUESTIONS is ignored.
Set the switches and TYPESAFE_API_KEY in the environment Grok is started
from; hooks inherit it. State goes to Grok's plugin data directory unless
STINGRAY_STATE_DIR is set.
STINGRAY_LANG still reads language from Claude Code's settings:
.claude/settings.local.json, then .claude/settings.json, in Grok's working
directory (cwd, which need not be the workspace root), then
~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json). Without one,
STINGRAY_LANG=1 does nothing under Grok.
To skip a workspace, list it in $GROK_HOME/stingray-deny (default
~/.grok/stingray-deny): one absolute path per line, covering that path and its
subtree. A deny file that exists but cannot be read skips every workspace.
The hook can be wired directly as a shell script:
git clone https://github.com/Nanako0129/stingray ~/stingray
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command",
"command": "/bin/bash \"$HOME/stingray/hooks/stingray.sh\"",
"timeout": 10 } ] }
]
}
}
Always set timeout. Claude Code's default hook timeout is 600 seconds; an explicit value prevents an unresponsive endpoint from holding a turn open for ten minutes.
bash, curl, and perl ship with macOS and standard Linux distributions. jq is not pre-installed on macOS. Without jq, the hook exits 0 before evaluating any shape, printing (stingray: unavailable — jq not found):
command -v jq || brew install jq # macOS
command -v jq || sudo apt install jq # Debian/Ubuntu
No SDK or additional runtime is required.
STINGRAY_ENDPOINT accepts HTTPS URLs, or plain HTTP restricted to loopback where local test servers run. Outgoing requests carry Authorization: Bearer; unencrypted requests to non-loopback destinations are rejected to avoid transmitting keys over plaintext connections.
Every check calls TypeSafe System One (jev-1.13.0): shapes 1 and 2, shape 3's promise and correspondence judgements, and the language check.
~/.config/typesafe/api_key (chmod 600), or export TYPESAFE_API_KEY. The configuration file is preferred because environment variables are exposed to child processes.Without an API key, stingray does nothing. It outputs (stingray: unavailable — no key; Jev checks skipped) once per session rather than failing silently.
Each switch controls evaluation and interception behavior. By default, all switches are unset; the hook exits immediately without running evaluations, issuing requests, or creating files.
Precedence and execution rules:
STINGRAY_SHAPE3=1 (watch check): Blocks turn completion when Jev judges the final message to promise a watch and nothing is running or scheduled. Needs an API key and sends the redacted final message. Does not ask shapes 1 and 2.STINGRAY_SHADOW=1 (shadow mode, highest precedence): Evaluates shapes 1 and 2 via Jev and writes decision records to disk, but never blocks turn completion. Overrides blocking behavior from both STINGRAY=1 and STINGRAY_SHAPE3=1. Start here once you configure an API key.STINGRAY=1 (active mode): Blocks turn completion on shapes 1 and 2 when Jev flags unfulfilled promises or missing actions.STINGRAY_LANG=1 (language check): Blocks turn completion when Jev judges the final message not to be in the configured language. Needs an API key and sends the redacted final message with one question; with STINGRAY=1 on too, it rides in the same request. It does not turn shape 3 on, and does not ask shapes 1 and 2.STINGRAY_SHAPE3_JUDGE=1 (shape 3 correspondence judgement): Allows shape 3 to block turn completion when background work is running but Jev judges it not to be what was promised. Requires STINGRAY_SHAPE3=1 and non-shadow mode. Defaults to off even when shape 3 is active, because its threshold is borrowed without dedicated offline measurement.| Variable | Effect |
|---|---|
| (nothing set) | Default. Hook exits immediately (code 0). Nothing runs, nothing is sent, no state files created. |
STINGRAY_SHADOW=1 | Calls Jev, logs decision records, never blocks turn completion. Outranks STINGRAY=1, STINGRAY_SHAPE3=1 and STINGRAY_LANG=1. Start here. |
STINGRAY=1 | Blocks turn completion on shapes 1 and 2 via Jev. |
STINGRAY_SHAPE3=1 | Blocks turn completion when Jev judges a promise to watch and nothing is running or scheduled. Needs a key; sends the redacted final message. Outranked by STINGRAY_SHADOW=1. |
STINGRAY_LANG=1 | Blocks turn completion on shape 4, a final message Jev judges not to be in the configured language. Needs a key; sends the redacted final message. Outranked by STINGRAY_SHADOW=1, which records it instead. |
STINGRAY_SHAPE3_JUDGE=1 | Allows shape 3's correspondence judgement to block turn completion (active tasks > 0, but model judges they do not match the promise). Off by default; requires STINGRAY_SHAPE3=1. Logs decisions regardless of switch state. |
The smallest configuration is STINGRAY_SHAPE3=1 alone: one question per turn, about whether the reply promised a watch. When something is running in the background, a second one (watch_mismatch) rides in the same request, carrying the tool list and background statuses as well, and is logged even while STINGRAY_SHAPE3_JUDGE is off.
Additional configuration options: STINGRAY_TAU (0.5), STINGRAY_TIMEOUT (6s), STINGRAY_MAX_BLOCKS (3 in a row), STINGRAY_STATE_DIR (~/.local/state/stingray), STINGRAY_REDACT_WORDS (extra terms to mask), and STINGRAY_JEV_MODEL (jev-1.13.0, pinned to prevent unannounced classifier changes).
Only these fields are transmitted, and only when an API key is configured:
| Field | Content |
|---|---|
final_text | The last assistant message, redacted, then truncated to the last 2400 bytes — roughly 800 CJK characters, but approximately 2400 ASCII characters. An English turn transmits roughly three times the character volume used in benchmark evaluations. |
tools | Tool names and invocation counts for this turn, excluding arguments. |
background | For each background task: status and description. For each scheduled cron: the assigned prompt. Both undergo message redaction after stripping credential patterns. Command lines are never sent. For a handoff still waiting, the other session's name only, never the message. |
language | The configured language, as a name — on the language question only, which carries this and final_text and nothing else. Its final_text has language names such as 簡體中文 or English replaced by 〔語言〕. |
User prompts sent to Claude are never transmitted. Tool arguments, file contents, diffs, and executed command lines are never transmitted.
Scheduled cron prompts are transmitted. Determining whether scheduled work matches what was promised requires reading the instructions assigned to that cron.
Redaction strips fenced code, block quotes, inline code spans, and URLs. It drops any line containing absolute paths, relative paths, or filenames, and masks commit SHAs, issue numbers, and project names. Project names are derived dynamically from the local working directory and git remote URL. Mentioned sibling projects cannot be inferred automatically; list them in STINGRAY_REDACT_WORDS if masking is required.
Truncation occurs after redaction. Performing truncation first can split code fences, breaking delimiter balance and causing code blocks to leak.
Before transmission, outbound bytes pass through two credential checks:
Authorization headers, bare Bearer or Basic tokens, fields named api_key, auth_token, secret, or password, and github_pat_.sk-, gh[pousr]_, github_pat_, AKIA, and PEM headers. Any match cancels the request and lets the turn proceed untouched../tests/show-payload.sh 50
This command runs the actual hook against a local recording server, writing the exact wire bytes to payload-audit.txt. It inspects actual network payloads rather than expected filter behavior.
Redaction does not achieve "zero private content", and no description here implies that standard.
Across 48 captured payloads from real turns, all eight tracked leak categories returned zero: fenced code, absolute and relative paths, filenames, URLs, commit SHAs, issue numbers, and line ranges. A zero indicates predefined patterns were not found, never that the payload is clean.
The substance of the work survives transmission. Reading those payloads reveals that a quota window was measured at 80% while 47 samples in the same window reported 77%, that a 60-second blind poll was running, and that six recovery files dated 2026-08-22 were present in a directory. Identifiers were masked; the operational task, the error encountered, and the planned resolution remained legible.
TypeSafe processes requests in the United States and does not publish a retention limit. Its Master Customer Agreement caps liability low enough that a leak leaves no practical financial remedy. Read the current terms rather than this summary of them — the terms change and the summary will not. Review payload-audit.txt before deciding to enable outbound requests. STINGRAY_SHADOW=1 transmits data over the wire. No outbound request is made by default. Every switch sends the redacted final message once it has something to ask about it.
Offline measurement against 124 turns from maintainer transcripts, labelled by whether the user typed "keep going" (jev-1.13.0, 2026-09-21):
| payload | precision | recall | FPR |
|---|---|---|---|
| full (redacted, ~800 chars) | 81.8% | 14.1% | 3.3% |
| last two sentences only | 61.1% | 17.2% | 11.7% |
| structured flags only | — | — | no_action never fires |
Per question, at full payload: no_action achieved 85.7% precision at 1.7% FPR; broken_promise achieved 66.7% at 3.3%. Restricting the payload to the last two sentences reduced precision by 20 percentage points and tripled false positives, which is why the full redacted message is sent.
High precision paired with low recall fits this design: an erroneous nudge wastes a turn, whereas a missed nudge leaves execution unchanged.
This experiment does not settle model accuracy. The measurement carries these limitations:
watch_claim and wrong_language alongside: the mean of either score moved by at most 0.04, the same as the most the identical request moved when sent twice (0.04), and no input moved across τ. Six inputs show no large effect, not no effect.broken_promise's wording changed in 0.3.5 and again in 0.3.9 (below), so its 66.7% and the 81.8% it contributes to describe the wording before 0.3.5. The 124 turns and the script that scored them were not kept, so neither can be rerun on the current one.Shape 2 is measured on the 28 labelled turns of tests/promise-fixture.tsv, each with a tool list and background written in the format the hook sends. Up to 0.3.4 it blocked replies that waited on background work actually running — "the verifier is running; once it confirms I'll open the PR" — nine times across five sessions in one day, at 0.50–0.72, and all nine were replies of that kind. Its false criterion now also covers an action that waits for work listed as running in background, judged by that field rather than by what the reply says. tests/promise-fixture.sh asks the question directly; two runs of the 0.3.4 wording and three of the 0.3.5 one, on the 22 rows the fixture had then:
| turns | 0.3.4 wording | 0.3.5 wording |
|---|---|---|
| declares an action for this turn, does not take it (block) | 0.73–0.95 | 0.75–0.96 |
| says work is running and it will act after, background empty (block) | 0.52–0.67 | 0.64–0.82 |
| something runs, but not the work the reply waits on (block) | 0.37–0.52, sometimes blocked | 0.16–0.19, never blocked |
| waits on background work that is running (pass) | 0.15–0.59, 2 blocked | 0.10–0.39 |
| reports finished work, or leaves a next step to the user (pass) | 0.08–0.14 | 0.09–0.15 |
The real replies came from private sessions; the fixture holds synthetic stand-ins of the same shape.
0.3.9 added to the false criterion an action that waits on something only the user can do — act on their own machine or screen, look at a result, authorise an irreversible step — and on their reply. "Fix Caps Lock, reply 好了, and I'll run A" blocked a real turn at 0.57 in 0.3.8. Replayed through the hook's redaction three times it scores 0.42–0.48 under the 0.3.8 wording, on the edge either way, and 0.16–0.20 under 0.3.9. Over three runs of 0.3.9: the three user-wait rows 0.16–0.23 (0.24–0.47 under 0.3.8, two runs), the user-now row still blocked at 0.88–0.92, and every other row agreeing with its label as under 0.3.8 except the two user-idle rows and the two bg-other rows. The table above is of the 0.3.4 and 0.3.5 wordings on the first 22 rows; its ranges were not re-measured under 0.3.9.
Shape 3's promise judgement is measured on the 50 labelled lines of tests/watch-fixture.tsv. Sent to Jev directly, the promises scored 0.68 and above except "keep an eye on the review" (0.49), and everything else 0.20 and below. tests/watch-fixture.sh re-runs them live through the shipped hook: at τ = 0.5 it agrees on all 49 it scores, with "keep an eye on the review" at the edge (see Known limits). One line, poll-coderabbit.sh 的參數解析有缺陷, names a file, so redaction removes it and it is never asked. The wording until 0.3.0 was measured on 59 lines (these 44 of the time and 15 synthetic, no longer kept); 0.3.1 stopped it counting "do this and tell me, then I'll read the log" as a promise, after such a reply blocked a real turn (replayed, it scores 0.53–0.58 under the old wording). On the same 50 lines the old wording gets 3 wrong, among them a synthetic stand-in for that reply (the original came from a private session and is not in the repository), and scores a non-promise as high as 0.82. Its correspondence judgement has never been evaluated. Its threshold is borrowed from the other questions without independent validation.
Two independent bars govern activation before either evaluator may block turn completion:
| Judge | Bar before blocking turn completion |
|---|---|
Shapes 1 and 2 (STINGRAY=1) | ≥ 40 shadow records, ≥ 70% precision on manual inspection, ≤ 3 false nudges per 100 stop points, τ placed in the empty band between score clusters with derivation documented |
Shape 3, promise (STINGRAY_SHAPE3=1) | None set. Measured on 50 labelled lines, and blocks from the first turn — see known limits |
Shape 3, correspondence (STINGRAY_SHAPE3_JUDGE=1) | ≥ 20 shadow records, ≥ 70% precision on manual inspection |
Shape 4, language (STINGRAY_LANG=1) | None set. Measured only on 20 synthetic replies, and blocks from the first turn — see known limits |
Records land in $STINGRAY_STATE_DIR/decisions.jsonl, one line per decision, tagged with qset_hash — the SHA-256 hash of questions.json, not the hash of the request. Modifying criteria shifts score distributions, voiding thresholds calibrated under earlier phrasing; a per-request hash would vary on every turn and could not detect criteria drift.
Measured at hook position in shadow mode between Taiwan and api.typesafe.ai, across three separate runs of 20 requests:
| p50 | p95 | budget | |
|---|---|---|---|
| shadow mode | 0.742–0.760s | 0.818–0.888s | 1.0s |
Results are reported as ranges because network latency cannot be reproduced to a single decimal across runs.
The 1.0-second budget applies to shadow mode as well: shadow mode executes the same network round trip. If your p95 exceeds this budget, disable the hook or lower STINGRAY_TIMEOUT. Reverting to shadow mode does not remove latency.
This budget covers healthy endpoints. When an endpoint hangs, execution waits until STINGRAY_TIMEOUT: measured at 6.15s with the default 6-second timeout against an unresponsive stub server. Lower STINGRAY_TIMEOUT if that delay is unacceptable during outages.
With STINGRAY_SHAPE3=1 or STINGRAY_LANG=1 alone, every turn with something to ask about makes this round trip too, where until 0.2.0 or 0.3.0 respectively it made none. A request carrying one question took 0.68–1.03s across 12 calls, measured by calling the endpoint directly with the same request shape rather than at hook position; through the shipped hook, 0.70–0.99s. With several switches on, their questions share one request, adding no round trip.
With STINGRAY_SHAPE3=1 the hook also reads the transcript for handoffs, locally and before the request: 0.32–0.72s on a 55 MB transcript at load average 14.
Two distinct guards prevent execution loops, avoiding single points of failure:
stop_hook_active from the harness evaluates to true on re-entry, preventing consecutive interventions on the same stop event.STINGRAY_MAX_BLOCKS, default 3) enforced independently of the harness flag. Under Claude Code, where the flag is measured, a run never gets past one block, so this limit only matters if the flag fails. Once the hook has read the count, every outcome other than a block — a pass, a fail-open, this limit, or a re-entry — starts it again; with the hook off, jq missing, no session_id or a state directory it cannot create, it never reads the count and leaves it as it was. Before 0.3.8 the count covered the whole session and never reset, so a long session that spent it got no checks at all from then on.block in tests/promise-fixture.tsv and fail on every run.block as user-idle and fail on every run.SendMessage result saying "another Claude session", and the answer arriving as <cross-session-message … from-name="…">. If the send's format changes, handoffs stop being seen and such turns block as before, as they do for a caller that supplies no transcript. If only the answer's format changes, the send is still recognised and its answer is not: the handoff stays pending for the rest of the session and covers a promise that should block.questions.json are written in Traditional Chinese, matching the corpus used for the 81.8% benchmark. English criteria have zero live measurements; replacing them voids that accuracy figure. Working in English requires rewriting criteria and recalibrating thresholds.background_tasks exists only at hook execution and cannot be reconstructed from saved transcripts. Its promise judgement can, and is — see tests/watch-fixture.sh.Claude Code v2.1.278, 2026-09-21. Official documentation differs on three counts, established by live execution:
| Item | Docs say | Reality |
|---|---|---|
| Blocking turn completion | exit 2 and print hookSpecificOutput JSON on stdout | exit 2 blocks the turn, but stdout never reaches the model. The reason must go to stderr |
stop_hook_active | Undocumented; implement custom tracking | Present, evaluates to true on re-entry |
stop_reason, scratchpad_dir, effort | Provided in payload | Absent |
Fields delivered to the hook: session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name, stop_hook_active, last_assistant_message, background_tasks, and session_crons.
Transcript record handling: each content block of an assistant message is written as an independent JSONL record, and promptId appears only on user records. Filtering assistant records by promptId causes turn tool counts to evaluate to zero every turn, triggering constant false positives on shape 1. See hooks/turn-tools.jq.
stingray/
├── .claude-plugin/ # plugin.json and marketplace.json
├── .grok-plugin/plugin.json # Grok manifest; selects hooks/hooks.grok.json
├── .coderabbit.yaml # review instructions, and the dated expiry of auto-review at <10 stars
├── .github/workflows/tests.yml # offline suites + mutation checks, Linux and macOS
├── hooks/
│ ├── hooks.json # Stop hook registration, explicit timeout
│ ├── stingray.sh # the whole thing: shapes, redaction, fail-open paths, nudge
│ ├── hooks.grok.json # Grok hook registration
│ ├── stingray-grok.sh # Grok adapter: payload map, tool list, denylist
│ ├── turn-tools.jq # slice one turn out of the transcript
│ └── handoffs.jq # handoffs to another session still waiting for an answer
├── questions.json # the five Jev questions — the classifier's contract, pinned to jev-1.13.0
└── tests/
├── acceptance.sh # drives the real hook against local stubs; no key, no network
├── network.sh # fail-open paths; --live also hits the real endpoint
├── watch-fixture.sh # live calibration of shape 3 on watch-fixture.tsv; needs a key
├── promise-fixture.sh # live calibration of shape 2 on promise-fixture.tsv; needs a key
├── hook-shell.sh # runs the hook on the interpreter hooks.json ships
├── mutants.sh # re-derives that each guard still fails when broken
├── grok-hook.sh # Grok manifest and adapter against local stubs
├── show-payload.sh # capture what would really be sent, locally
├── stub_server.py # local stand-in for the endpoint; --list-modes lists its modes
└── latency.py # p50/p95 against a budget, exits non-zero over it
./tests/acceptance.sh # drives the real hook against local stubs: no key, no network
./tests/network.sh # fail-open paths against a local stub server
./tests/network.sh --live # also the real endpoint, with synthetic text only
./tests/mutants.sh # do the guards still guard?
./tests/grok-hook.sh # Grok adapter against local stubs; GROK_HOOK_MUTANT=<name> must fail
./tests/show-payload.sh 50 # capture what would really be sent, locally
./tests/watch-fixture.sh # shape 3 against the real endpoint on labelled lines; needs a key
./tests/promise-fixture.sh # shape 2 against the real endpoint on labelled turns; needs a key
Tests drive the actual hook with real stdin and assert on exit codes and stderr. No test inspects source code directly. Live tests send synthetic text to avoid leaking conversation data.
Two test cases guard against specific implementation errors, verified by mutants.sh:
background_tasks key must not evaluate as "nothing is running", which would cause shape 3 to block whenever a promise is judged.stingray is free and requires no account. The only operating cost is your own TypeSafe API usage ($0.042 per million input tokens; an average turn consumes well under 1,000 tokens). You can support the maintainer on Patreon.
MIT
decisions.jsonl will show — its records are wrong_language and language_ok.「軟件」「用戶」) still blocks, at 0.77–0.79: the placeholder removes the language's name, not the quoted words.StopFailure, not Stop, which would keep them away from this hook. That is documented, not measured. If one does arrive, shape 4 judges it once and the re-entry guard stops a second.use_tool, the name Grok's log gives it.language from Claude Code's settings files, and with none of them present STINGRAY_LANG=1 checks nothing.network.sh --live encountered an 8/9 result on a single test run; five subsequent reruns could not reproduce the failure, and the failing check was not identified. This occurrence is documented in test comments.