semantic-linefeeds
AI coding agents know where a sentence ends — they just don't wrap lines there.
Line breaks are for meaning, not margins.
A diff-aware prose guardrail for AI coding agents and source repositories. Instructions alone don't reliably prevent it, and a deterministic check at the tool boundary catches it.
The Problem:
When comments and docs are wrapped to a fixed column width, changing one word forces the whole paragraph to reflow, and the diff for that one-word edit touches every line below it.
Telling a coding agent "one sentence per line" in your instructions doesn't fix this either: instructions alone do not reliably prevent non-semantic breaks, however prominently the rule is stated.
What This Kit Does:
semantic-linefeeds enforces semantic line breaks at the moment text is written — in code comments, doc comments, docstrings, and Markdown.
Because each line holds one sentence (or one clause of a long sentence),
an edit touches only the line whose meaning changed,
and git blame keeps pointing at the sentence that changed.
Adapters wire the same dependency-free Python detector into Claude Code, Codex CLI, opencode, and — through a portable AGENTS.md snippet — any agent that reads instruction files.
Why Semantic Line Breaks
SemBr breaks lines by meaning instead of by column: one sentence per line, and a long sentence splits only at a real clause boundary. The rendered output doesn't change — Markdown still joins the lines back into one paragraph.
The difference shows up in diffs. Add two words near the top of a column-wrapped paragraph (wrapped at 72 characters here), and the rewrap ripples through every line below it:
-The exporter batches metrics in memory, flushes them once per minute,
-and retries failed uploads with exponential backoff until the queue
-drains.
+The exporter batches metrics and traces in memory, flushes them once per
+minute, and retries failed uploads with exponential backoff until the
+queue drains.
The same edit under semantic line breaks touches only the one line whose meaning changed:
-The exporter batches metrics in memory,
+The exporter batches metrics and traces in memory,
flushes them once per minute,
and retries failed uploads with exponential backoff until the queue drains.
Reviewers see exactly the word that changed, and blame on any sentence finds the commit that wrote it.
Quickstart
uv tool install semlf # or: pipx install semlf
semlf install # detects agents, lists every path it would write, asks y/N
semlf doctor # replays the payloads end to end
Claude Code stays on its own plugin marketplace and is never touched by semlf:
claude plugin marketplace add arloliu/semantic-linefeeds
claude plugin install semantic-linefeeds@semantic-linefeeds
A local checkout works the same way — point the first command at its path instead:
claude plugin marketplace add /path/to/semantic-linefeeds
claude plugin install semantic-linefeeds@semantic-linefeeds
| Agent | Command |
|---|---|
| Codex CLI | semlf install codex |
| opencode | semlf install opencode |
| Claude Code | the marketplace pair above |
| Anything else (AGENTS.md) | semlf install agentsmd PATH |
semlf install with no target detects which agents are present
and proposes one plan covering all of them, then asks y/N.
Naming a target instead — codex, opencode, or agentsmd PATH — applies that one target immediately.
Pick one channel for the semlf command itself.
A zipapp left at ~/.local/bin/semlf and a uv tool install/pipx install shim would otherwise shadow each other;
semlf install's end-of-run PATH check, and semlf doctor, both name the collision when they see one.
What Gets Installed
| Component | Location | Purpose | Who needs it |
|---|---|---|---|
| Checker | ${XDG_DATA_HOME:-~/.local/share}/semlf/check_linefeeds.py |
The enforcement core every installed hook and skill runs | Codex CLI (the hook's target) |
| README | published beside the checker, at the same neutral root | Resolves the installed skill's suppression-rules link, even on an air-gapped machine | Codex CLI |
| Codex hook entry | $CODEX_HOME/hooks.json (default ~/.codex/hooks.json) |
Runs the checker after every edit; blocks fused, reports wrap/long as advice |
Codex CLI |
| Codex skill | ~/.agents/skills/semantic-linefeeds/SKILL.md |
The judgment layer: clause-boundary calls, suppression syntax, the disagreement rule | Codex CLI |
| opencode plugin | the opencode plugins directory ($XDG_CONFIG_HOME/opencode/plugins, default ~/.config/opencode/plugins) |
Wires the checker into opencode's edit/write/apply_patch tool output | opencode |
| opencode's checker copy | the same opencode plugins directory, beside the plugin | The plugin resolves its checker next to itself, not at the neutral root | opencode |
| AGENTS.md snippet | the file you name with semlf install agentsmd PATH |
The judgment layer for any agent with no native skill mechanism | Any AGENTS.md-reading agent |
On the package channel, the semlf package is the installer and cannot be skipped —
but its check commands (semlf check, semlf --staged, and so on) stay optional after that.
A guardrail-only machine that wants no CLI at all is the checkout door's offer instead:
install.sh --codex, with no --cli, writes the hook, the skill, and the neutral-root payloads, and stops there
(see Air-gapped and mirror installs).
Why Telling an Agent Isn't Enough
Writing the rule into AGENTS.md, CLAUDE.md, or any other instruction file doesn't hold up at generation time: agents keep producing non-semantic line breaks with the rule in plain sight.
So this kit backs the rule with three layers instead of trusting the model to remember it:
- Hook —
a post-edit hook runs a deterministic detector over the text just written to any supported file.
Violations come back as feedback the moment they happen,
which is the one channel that survives a long session.
Only
fusedstops the edit; a long line comes back as advice, because leaving it long is often the right answer.wrapis not reported to the model at all, because a labeled corpus measured its false positives and the number was not small enough to act on. SetSEMLF_EXPERIMENTAL_WRAP=1to see it anyway, as advice that never blocks, or setexperimental-wrap = truein.semlf.inito opt a whole project in. - Skill —
semantic-linefeedscarries the judgment calls the detector can't make on its own: what counts as a clause boundary, the compound-objectandtest, the never-break list, and the bounded disagreement rule — judge a finding before rewriting, and a believed false positive or a finding that survives one repair goes to the user instead of another rewrite. Claude Code ships it as a plugin skill;semlf install codexwrites a native copy for Codex CLI; other agents fall back to the AGENTS.md snippet. Hook feedback names the skill only when a usable copy is present at a location Codex resolves skills from. - Detector —
scripts/check_linefeeds.py, dependency-free Python 3. Three precision-tuned heuristics:fused(two sentences on one line),wrap(a line severed mid-clause), andlong(over a configurable limit, default 120 chars, with a likely clause boundary).--fileaudits report all three, because an audit is read by the person who asked for it. The hook reads a stable snapshot of the edited file for context, so it can report a real line number and skip findings an edit didn't touch, and it falls back to checking only the text just written when that mapping fails. Either way, legacy column-wrapped files are never flagged into a noisy rewrap.
Suppressing a finding
Two directives, each scoped to exactly one line:
semlf-ignore— withholds every finding anchored on the line carrying it.semlf-ignore-next— withholds every finding anchored on the next line.
Standalone, on a line of its own:
<!-- semlf-ignore-next -->
A line the checker will leave alone.
Trailing, after the line it judges:
A long judged line that runs on past the limit. <!-- semlf-ignore long -->
A two-line wrap finding anchors on its upper line,
so a trailing carrier suppressing it must sit on that first line, not the second.
Each directive takes zero or more kind arguments (fused, wrap, long);
no arguments suppresses every kind on that line.
A recognized directive name with any unrecognized argument is malformed and inert —
it suppresses nothing, and the findings it would have hidden stay visible.
Suppression is user-directed.
An agent never adds a semlf-ignore or semlf-ignore-next directive on its own authority:
when it judges a finding to be a false positive,
it leaves the text as written and raises the disagreement with you instead.
Suggested replacements
A blocking fused report on ! or ? carries an exact two-line replacement
when the split is unambiguous (ADR-0007):
Suggested replacement for line 12:
Stop now!
Go on.
The suggestion exists only for the !/? automatic class, never for a period boundary.
It is never applied for you —
apply it as your next edit, after judging the finding like any other.
Configuration
.semlf.ini at the repository root holds every project setting the checker reads,
one [semlf] section, three optional keys:
[semlf]
long-limit = 100
experimental-wrap = true
exclude =
docs/legacy/
*.gen.md
long-limit— the long-line advisory threshold, in characters (0disables the advisory). Precedence is the--long-limitflag, then$SEMLF_LONG_LINE, then this key, then the built-in default of 120. Fused and wrap findings are never affected — only the advisory threshold moves.experimental-wrap— optswrapfindings back into hook feedback, arriving as advice that never blocks an edit (ADR-0017).$SEMLF_EXPERIMENTAL_WRAPdecides outright whenever it is set to a non-empty value —0,false,no, oroffreads as off, anything else as on — so it can force the kind on for a repo that never opted in, or off for one that did.exclude— one glob pattern per line, matched per path segment; a trailing/names a folder, excluded at any depth unless an inner/anchors a chain at the config root (see "Excluding paths" below).
The flag and the environment variable always win over the file (ADR-0012).
The core discovers .semlf.ini by walking upward from the checked file
and stops at the first directory holding either the file or a .git entry,
so a config never leaks across a repository boundary.
A missing, malformed, or unparseable file is inert —
the checker falls back to the next precedence leg rather than failing the run,
and an invalid value in one key never silences another.
Hook mode skips paths under the platform temp directory and any tmp/ component,
so agent scratch files are never flagged.
--file mode always checks exactly the paths you name.
Excluding paths
exclude takes one pattern per line;
the three shapes below are worth seeing side by side:
[semlf]
exclude =
vendor/
docs/generated/
*.generated.md
A trailing / names a folder — a bare name like vendor/ excludes it at any depth,
while an inner / before the trailing one anchors a chain at the config root —
today the repository root, where .semlf.ini lives (ADR-0012) —
so docs/generated/ excludes only that path, not plugins/docs/generated/.
A pattern without a trailing / is a glob:
with a / it must match the whole relative path, segment by segment;
without one, like *.generated.md, it matches any single path component at any depth.
Matching is case-sensitive everywhere.
Excludes filter discovery only — hook mode and the three git modes below.
A path you name explicitly on --file or check is always checked,
exclude or no exclude, because naming a path is the judgment call excludes exist to encode.
An agent never adds an exclude line on its own authority (ADR-0010's principle);
it raises the disagreement with you instead.
Checking git snapshots
Three more modes check a git snapshot instead of files you name:
semlf --stagedchecks the index — whatgit commitwould record — reading each staged blob by its own object id.semlf --diffchecks the worktree copy of every unstaged change against the index.semlf --changedchecks the worktree copy of everything different fromHEAD, staged and unstaged together.
All three accept --json and --long-limit N, the same as --file.
Only tracked changes are enumerated;
an untracked file needs git add before any mode can see it.
Symlinks are never checked, in any mode.
Policy — .semlf.ini, including exclude — is always read from the working tree,
even for --staged: its content is the index, but its policy is the checkout it runs in.
A config that is staged but not yet on disk does not yet govern --staged (ADR-0013).
Pre-commit
repos:
- repo: https://github.com/arloliu/semantic-linefeeds
rev: <tag>
hooks:
- id: semlf
language: python lets pre-commit build this repository into its own environment,
so semlf does not need to be installed or on PATH beforehand.
Supported Languages
| Languages | Extensions |
|---|---|
| C, C++, Objective-C | .c .h .cc .cpp .hpp .hh .m .mm |
| Java, Kotlin, Scala, Groovy | .java .kt .kts .scala .groovy .gradle |
| JS/TS | .js .jsx .ts .tsx .mjs .cjs |
| C#, VB.NET | .cs .vb |
| Go, Rust, Zig | .go .rs .zig |
| Python | .py .pyi |
| Swift, Dart, PHP | .swift .dart .php |
| shell, PowerShell | .sh .bash .ps1 .psm1 .psd1 |
| Ruby, Perl, Lua | .rb .rake .pl .pm .lua |
| SQL, R, Haskell, Elixir | .sql .r .R .hs .ex .exs |
| Markdown | .md .markdown .mdx |
Recommended Instructions Companion
The one habit that survives inline generation belongs in your agent's global instruction file — AGENTS.md for most agents, CLAUDE.md for Claude Code. Everything else lives in the skill and the hook:
## Comment and doc formatting
One sentence per line in all comments and Markdown (semantic linefeeds).
After writing any text block, act on whatever the linefeeds hook reports.
A blocked edit must be fixed; an advisory is a judgment call, and leaving the line alone can be right.
Appendix
Air-gapped and mirror installs
The package channel's git+URL form still works when a machine can't reach PyPI:
pipx install git+https://github.com/arloliu/semantic-linefeeds
# or
uv tool install git+https://github.com/arloliu/semantic-linefeeds
For a fully offline or mirrored network, the checkout door covers every artifact this kit installs.
One command clones (or updates) a checkout under ${XDG_DATA_HOME:-~/.local/share}/semantic-linefeeds
and hands the remaining arguments to scripts/install.py,
the same shared lifecycle engine behind semlf install:
curl -fsSL https://raw.githubusercontent.com/arloliu/semantic-linefeeds/main/install.sh | sh -s -- --codex --cli
Pass the flag for your agent to the one-liner above, or to python3 scripts/install.py inside a checkout.
Passing no flag prints a status report of what's installed where.
| Agent | Installer flag |
|---|---|
| Codex CLI | --codex |
| opencode | --opencode |
| Anything else | --agentsmd PATH |
Add --cli to build the semlf zipapp and install it as ~/.local/bin/semlf —
the one channel that needs no package index at all,
and the one the package door deliberately has no equivalent for:
pipx, uv, and the zipapp all want the same ~/.local/bin/semlf shim,
so building and removing the zipapp stays exclusive to the checkout door.
install.sh reads its clone source from --repo or the SEMLF_REPO env var,
and the checker itself never touches the network.
Mirror this repo to your internal git host,
curl the script from the mirror's raw endpoint,
and point SEMLF_REPO back at the mirror:
# GitLab raw path shown; Gitea/Forgejo use /raw/branch/main/ instead of /-/raw/main/
curl -fsSL https://git.internal/you/semantic-linefeeds/-/raw/main/install.sh |
SEMLF_REPO=git@git.internal:you/semantic-linefeeds.git sh -s -- --codex
Export SEMLF_REPO once in your shell profile,
and every later re-run installs and updates from the mirror without repeating it.
--ref/SEMLF_REF pins a tag or branch;
--home/SEMLF_HOME moves the checkout.
For Claude Code,
claude plugin marketplace add git@git.internal:you/semantic-linefeeds.git covers the same case.
The Vercel skills CLI can fetch the judgment-layer skill on its own,
as a supplement rather than an install path.
It copies SKILL.md and nothing else,
so it cannot install the hook —
this kit's load-bearing layer, the one that surfaces findings at generation time —
and an agent that only has the skill this way still needs semlf install or the checkout door for the hook.
Lifecycle
semlf install [TARGET...] # detect agents and propose a plan, or apply one named target
semlf status [agentsmd PATH] # report every discoverable or recorded artifact's state
semlf uninstall TARGET... # preflight-then-apply removal of a target's artifacts
semlf doctor # replay a payload end to end, report evidence
Upgrading is a two-command pair:
uv tool upgrade semlf && semlf install
# or: pipx upgrade semlf && semlf install
The first command updates the semlf command itself, its embedded payloads included;
the second re-applies the current payload set over whatever is already installed.
Upgrades are provenance-aware:
re-running an installer replaces an untouched older release silently.
A release newer than the one already published is a downgrade and refuses by default;
--force states the intent and replaces it, no backup, since a managed file is never the only copy of anything.
An edited or unrecorded file always refuses first;
--force there takes an exclusive backup to <name>.bak before replacing it,
and an occupied backup slot refuses either way.
Uninstalling one integration never deletes the neutral root's published checker or README:
their independence from any single integration is the point of the neutral root,
so they are left in place once the last integration that used them is gone,
and semlf status lists the specific leftover file paths in one line for manual removal.
A zipapp left over from before this redesign is a migration case:
semlf install runs a PATH check at the end of every run
and warns when semlf on PATH is not the artifact that just ran;
semlf status and semlf doctor repeat the warning,
with the checkout-door removal pointer (install.py --uninstall --cli).
Testing
python3 -m pytest tests/ -q # full suite: CLI, detector, extractor, install/doctor
python3 scripts/check_linefeeds.py --file <files> # audit files by hand
bun test adapters/opencode/ # opencode plugin's own unit tests (needs bun)
Detector fixtures live under tests/fixtures/<language>/;
inline {fused}, {wrap}, or {long} markers mark the lines that should be flagged.
good_* fixtures must carry zero markers, and a dedicated test enforces it.
The extractor has golden tests under tests/extractor/.
After changing extraction logic,
regenerate with python3 -m pytest tests/test_extractor.py --update-golden and diff-review the result.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file semlf-0.7.0.tar.gz.
File metadata
- Download URL: semlf-0.7.0.tar.gz
- Upload date:
- Size: 191.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c9fce05d38dc7315cc29339ad42dd398e085e13406b0dc17fdefb1f82d2663c
|
|
| MD5 |
4c60c5edfe1aa1cc3506a470d38340d6
|
|
| BLAKE2b-256 |
e0110e8c846341d9552b8864a51565348bb3cd37a63d908e6b8ba67f344d788c
|
File details
Details for the file semlf-0.7.0-py3-none-any.whl.
File metadata
- Download URL: semlf-0.7.0-py3-none-any.whl
- Upload date:
- Size: 142.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e048b1d2a47a4fbb7b9305da110702008d357c4551bef07d6da54e4d22a265df
|
|
| MD5 |
cb9e6b72710043ce31b82c29eab447fa
|
|
| BLAKE2b-256 |
dbf5d7854369d5c544494c84dbf24cc6a8dba348116e9b4ce5acee0881d6be86
|