# ste-guard

A Claude Code plugin that keeps Claude's replies in Simplified Technical English.

Claude talks too much. This makes it talk less.

## What it does

1. A `SessionStart` hook injects a short rule card one time.
2. A `Stop` hook checks each reply. When a rule fails, the hook blocks once and returns the
   violations. Claude rewrites and sends the clean version.
3. A `UserPromptSubmit` hook injects the full rule text with the exact banned strings. In
   the default lazy mode this hook stays silent until the `Stop` hook blocks once.
4. A `PreToolUse` hook checks the markdown a `Write` or an `Edit` puts on disk. When a rule
   fails, the hook denies the call and returns the violations. It reads markdown only.
5. A `ste-check` command lets Claude lint a draft before it sends the draft.

The rules come from ASD-STE100, adapted for chat. This is not certified conformance. The
ASD licenses the official approved dictionary, so this repository does not carry it.

## Why the injection is lazy

An always-on rule injection costs about 1,000 tokens per turn. A 50-turn session pays 50,000
tokens to repeat rules that Claude read at the start.

The lazy mode pays once at session start, then nothing. When Claude breaks a rule, the hook
arms itself and injects the full contract for the rest of the session. A clean session pays
nothing beyond the opening card.

Set `injection` to `always` when you prefer the predictable cost.

## Install

```
claude plugin marketplace add https://github.com/ofrusch/ste-guard.git
claude plugin install ste-guard@ste-guard
```

The `owner/repo` shorthand also works, but Claude Code clones that form over SSH. The full
HTTPS URL above clones over HTTPS instead, so it works without an SSH key. To keep the
shorthand and still clone over HTTPS, set `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1`.

Optionally set the output style to `STE` with `/output-style`. The hooks work without it.

## Install for Codex

Codex reads the same marketplace file, so the first two steps match.

```
codex plugin marketplace add https://github.com/ofrusch/ste-guard.git
codex plugin add ste-guard@ste-guard
python3 ~/.codex/plugins/cache/ste-guard/ste-guard/<version>/scripts/codex-install.py
```

The third step exists because Codex v0.147 installs a plugin's skills but does not load the
hooks the plugin ships. The script writes the four hook entries into `~/.codex/hooks.json`
by absolute path. It backs the file up first, it leaves your other hooks alone, and it never
stacks duplicates. Run it again after a plugin update, because the version sits in the path.

Codex asks you to trust each new hook command on the next interactive run. Until you approve
them, Codex skips the hooks without a warning. Run `codex` once and accept the four prompts.

Remove the entries with `--uninstall`.

### What differs on Codex

- A Codex `Stop` hook that blocks does not reject the reply. Codex continues the turn with
  the violations as a new prompt, so the first draft stays in the transcript.
- The `ste` skill loads from the plugin with no extra step.

## Install for pi

```
pi install npm:ste-guard
```

The package carries the extension and the skill. pi loads the TypeScript through jiti, so no
build step runs. Install from a checkout with `pi install ./ste-guard`, or try it for one
session with `pi -e ./extensions/ste-guard.ts`.

### What differs on pi

- pi has no block primitive on an assistant message. The extension asks for a rewrite
  through a follow-up user message instead. The model regenerates, and both the first draft
  and the correction stay in the transcript.
- pi streams the reply before the check runs, so you see the first draft appear.
- The extension holds no rules of its own. It runs the same Python checker the other two
  agents run.

## Pick your features

Run the setup script for the questions. It writes `~/.claude/ste-guard.json`, and it backs up
the file it replaces.

```bash
python3 ~/.claude/plugins/ste-guard/scripts/ste-setup.py
```

Inside a session, run `/ste-setup` instead. Claude asks the questions and runs the script.

| Flag | Effect |
| :-- | :-- |
| `--show` | Prints the current config |
| `--profile default\|peer-eng` | The register to extend |
| `--write-guard deny\|warn\|off` | What the write guard does with a failing file |
| `--injection lazy\|always\|never` | When the hook injects the full rule text |
| `--telemetry on\|off` | Records one line per turn, with no message text |
| `--rules-off a,b` | Turns named rules off |
| `--rules-on a,b` | Turns named rules on |
| `--write-suffixes .md,.mdx` | The file types the write guard reads |
| `--reset` | Deletes the config and falls back to the bundled default |

`STE_GUARD_PROFILE` wins over this file. Unset it before you run the setup script.

## Configuration

ste-guard resolves the active profile in this order:

1. The `STE_GUARD_PROFILE` environment variable, read as a bundled profile name or a file
   path.
2. The file at `~/.claude/ste-guard.json`.
3. The bundled `profiles/default.json`.

A user profile does not need to restate the whole schema. Name a parent with `extends`, then
override only the keys you want.

### Profile schema

| Key | Type | Meaning |
| :-- | :-- | :-- |
| `extends` | string | A bundled profile name to inherit from |
| `injection` | string | `lazy`, `always`, or `never`. `off` is an alias for `never` |
| `min_words_to_lint` | number | Replies shorter than this are never checked |
| `soft_block_threshold` | number | Soft violations needed to block on their own |
| `max_blocks_per_message` | number | Blocks allowed against one message digest |
| `max_blocks_per_chain` | number | Consecutive blocks before the hook gives up |
| `budget.words` | number or null | Word ceiling per reply. `null` removes the cap |
| `budget.sentence_words` | number or null | Word ceiling per sentence |
| `budget.uncapped_on_heading` | boolean | A markdown heading lifts the word ceiling |
| `rules.*` | boolean | One switch per rule. See the table below |
| `lists.*` | array | Replaces a phrase list |
| `lists_add.*` | array | Appends to a phrase list |
| `lists_remove.*` | array | Removes entries from a phrase list |
| `autofix_adjectives` | array | Extra words the `--fix` pass may delete before a noun |
| `write_guard.enabled` | boolean | The write guard switch |
| `write_guard.mode` | string | `deny` blocks the write. `warn` reports and lets it land |
| `write_guard.profile` | string | The profile the write guard lints with |
| `write_guard.suffixes` | array | The file types the write guard reads |

### Rule switches

| Switch | What it catches |
| :-- | :-- |
| `openers` | Filler openers such as a warm-up compliment |
| `closers` | Hollow sign-offs |
| `puffery` | Marketing adjectives |
| `not_just` | The "not just X, it is Y" construction |
| `asides` | Mid-sentence parenthetical and em-dash interruptions |
| `gerund` | A sentence that opens with an -ing form |
| `passive` | Passive voice |
| `narration` | Process narration, which reports the search instead of the finding |
| `flourish` | Wordplay and rhetorical symmetry |
| `prose_wall` | A long reply with dense paragraphs and no bullets |

### Bundled profiles

- `default` — the ASD-STE100 chat subset with a 250-word ceiling. Grammar only, no voice.
- `peer-eng` — a blunt senior-engineer register. A 130-word ceiling, bullets by default,
  every rule on.
- `docs` — full STE for a file on disk. No word ceiling, every phrase rule on. The write
  guard always runs this profile, whatever the session runs.

### Example

Write `~/.claude/ste-guard.json` to keep the grammar rules and drop the word ceiling:

```json
{
  "extends": "default",
  "budget": { "words": null },
  "lists_add": { "puffery": ["ecosystem", "journey"] }
}
```

## Fix the reflex failures without a rewrite

```
ste-check --fix draft.md
echo "$DRAFT" | ste-check --fix
```

The fixer deletes a filler opener, a hollow closing sentence, and a puffery adjective that
stands before a noun. It never rewrites a sentence. It reports every fix on stderr, and it
lists what still needs a person.

The fixer is deliberately timid. It leaves a predicate use such as "the parser is robust"
alone, because a deletion there breaks the sentence. It leaves a hyphenated entry alone,
because the phrase list holds stems.

Two rules keep the fixer from deleting meaning:

- It deletes a known adjective only. A word that also reads as a noun or a verb stays, so
  "the leverage ratio" never becomes "the ratio". Name your own safe words with
  `autofix_adjectives` in a profile.
- It deletes a closing sentence only when the rest of that sentence is filler. A sentence
  that also carries a number, a path, or an imperative verb stays whole.

## Measure the rules

Telemetry is off by default. Turn it on with `"telemetry": true` in your profile, or with
`STE_GUARD_TELEMETRY=1`. Each turn appends one line to `telemetry.jsonl` in the state
directory. The line holds counts and rule numbers only. It never holds your message text.

```
scripts/ste-stats.py
```

The reader prints the block rate, the mean words per turn, the violations per 100 words, and
the share each rule contributes. Use it to find the rules that only nag, then switch them off
in your profile.

## Quoted text is exempt

The checker blanks any run inside double quotes before it applies the phrase lists. Claude
can therefore name a banned phrase when it explains the phrase to you. Claude cannot use one.
Inline code, fenced code, block quotes, links, and file paths are also exempt.

## Environment variables

| Variable | Effect |
| :-- | :-- |
| `STE_GUARD_OFF=1` | Disables every hook |
| `STE_GUARD_WRITE_OFF=1` | Disables the write guard, and leaves the reply guard on |
| `STE_GUARD_DEBUG=1` | Writes the hard and soft counts to stderr |
| `STE_GUARD_PROFILE` | Selects a bundled profile by name, or a profile file by path |
| `STE_GUARD_TELEMETRY=1` | Records one line per turn, without the message text |
| `STE_GUARD_STATE_DIR` | Moves the block counters and the telemetry log |

## Limits

- No Claude Code hook rewrites a reply in place. The `Stop` hook can only block and hand back
  the violations. A rewrite therefore costs a second generation.
- The word ceiling and the bullets rule need judgment, so a regular expression cannot fix
  them. Only a rewrite can.
- The checker matches fixed strings. A model that invents a new filler phrase gets through
  until you add the phrase to a list.
- The write guard reads `.md`, `.markdown`, and `.mdx` only. A comment in a source file, a
  commit message, and a PR body all pass with no check.
- The write guard reconstructs the file the tool is about to write, then reports only the
  violations that call introduces. A defect already on disk never blocks an unrelated edit.
- The write guard reads the tool names `Write`, `Edit`, and `MultiEdit`. Another agent that
  edits a file under a different tool name gets through.

## Tests

Both suites use only what the runtime ships, so neither needs an install step.

```
python3 -m unittest discover -s tests -v
node --test tests/extension.test.ts
```

Four of the tests run the plugin's own documentation through the checker, so the docs cannot
drift out of conformance without a red build.

## Related work

- [AminBlg/SimpleEnglish](https://github.com/AminBlg/SimpleEnglish) — an STE skill and plugin
  that gates file writes through a `PreToolUse` hook, with published benchmarks.
- [danyuchn/asd-ste100-skill](https://github.com/danyuchn/asd-ste100-skill) — the ASD-STE100
  rules packaged as a skill.

ste-guard differs in one way. It gates the reply, not the file write.

## License

MIT
