Products Consulting About Blog Contact Us Česky
arrow_back Back to blog

Linting the Prompt Layer

Linting the Prompt Layer

The blog has been quiet since July, for two reasons.

The first was three weeks above the treeline, across northern Italy and Austria. Two places stand in for the rest.

Arco, at the northern tip of Lake Garda, with the Brenta Dolomites overhead — limestone that looks drawn rather than eroded, and paths that spend more time vertical than not.

The Brenta Dolomites seen from a col above Lake Garda, with a rifugio and hiking paths tracing the green basin below

And in Austria, the Krimml falls — big enough to see from the car park, with a path that climbs alongside them.

The Krimml waterfall in Austria, a heavy column of white water dropping into a spray-filled gorge lined with pine

The second reason was work, and more of it than usual: a client sprint against a fixed deadline, and then another hackathon in the format we wrote about in June — a week of parallel agents, with nothing left in the evenings for writing.

A colleague brought agnix into the client project — a linter for the files that configure AI coding agents. It stuck. We adopted it there, then on CrushLog, and one small use case ended up on this blog — small enough to walk through end to end, which is what the rest of this post does.

It also closes something we left open. Three months ago we wired ArchUnit into the CrushLog backend and wrote about it. One line from that post has been quietly bothering us ever since:

Our CLAUDE.md already encoded the rules in prose. Prose doesn’t fail a build.

We used that line to justify translating Java invariants into ArchUnit tests. Fair enough. But read it again and notice what it admits: CLAUDE.md itself is still prose. The file that steers the agent — the one telling it how to write the code we then carefully verify — was exempt from the standard we were applying to everything downstream.

So we fixed that. We turned our blog conventions into a skill with checks that fail, pointed agnix at the skill, and put the whole thing on GitHub as SaaSForge-s-r-o/claude-skills.

The linter found things. Some of them were in this website.

Agent config fails silently, which is the whole problem

A Java compile error stops you. A malformed SKILL.md does not. The frontmatter fails to parse, the skill never registers, and your session proceeds exactly as if you had never written it — no error, no warning, no degraded mode. You just quietly get the default behaviour and assume your instructions are working.

That failure mode is why a linter for this layer makes sense at all. The usual defences don’t apply:

  • Testing doesn’t help, because there’s no assertion to make. The skill didn’t crash; it wasn’t there.
  • Review doesn’t help, because a reviewer reads what the file says, not whether the tool parsed it.
  • Trying it doesn’t reliably help either, because a skill that fails to load is indistinguishable from a skill that loaded and didn’t happen to trigger.

agnix is a Rust CLI that validates this layer — CLAUDE.md, SKILL.md, AGENTS.md, subagent definitions, hooks, MCP configs, plugin manifests — across the major assistant formats. We ran version 0.52.2.

Step one: prose becomes gates

Our conventions were documented, and had been for a year. Front matter fields, translationKey linking the English and Czech versions, cover images at 1200×630, credit the photographer, future dates are scheduled not broken.

We turned the enforceable subset into four checks:

GateWhat it checks
Front matterRequired keys present and typed; date matches YYYY-MM-DD; tags is a list
Language parityEvery post exists in every language with an identical translationKey
Cover imageThe referenced file exists and matches the configured dimensions
Buildhugo --buildFuture exits 0

The whole thing runs as a PostToolUse hook, so a broken post is caught at write time rather than at build time.

Two design decisions turned out to matter more than the checks themselves.

A validator that doesn’t understand its input must fail, not guess. We wrote a small front-matter parser rather than take a PyYAML dependency, because hooks run in the user’s environment and can’t assume one is installed. That’s fine — until the parser meets something outside the subset it handles. The temptation is to skip the line and carry on. We made it an error instead:

Python
if ":" not in raw:
    raise FrontMatterError(f"unparseable line: {raw.strip()!r}")

A parser that silently accepts input it doesn’t understand is worse than no parser, because it produces a green check over an unread file.

“Skipped” is not “passed.” The build gate runs Hugo. On a machine without Hugo, the first version returned “no problems” — which is technically true and completely misleading. A CI runner missing Hugo would report a green build gate having never built anything. Now a skipped gate says so:

Python
except FileNotFoundError:
    if notices is not None:
        notices.append(f"build gate skipped: {shlex.split(command)[0]!r} not on PATH")
    return []

This is the single failure mode that would make the entire exercise worthless. A check that reports “not run” as “passed” is worse than no check, because it manufactures confidence.

Step two: what the linter caught

On this website: 19 warnings, 16 of them wrong

The first run against this Hugo repo found nineteen warnings. Sixteen were XP-003, “hard-coded Claude Code path may cause portability issues”, pointing at lines like this one:

Text
content/en/blog/claude-code-skills-guide.md:16:32
  warning: Hard-coded Claude Code path '.claude/' may cause portability issues

That’s not configuration. That’s a published article about .claude/skills/, where the path is the subject matter. agnix walks Markdown and applies Claude Code rules to anything that looks like agent config, and a Hugo content/ tree full of articles about agent config is, by that heuristic, indistinguishable from the real thing.

The fix belongs in configuration, not in the articles. Editing correct documentation to satisfy a linter would be the tail wagging the dog:

TOML
# .agnix.toml
exclude = [
    "content/**",   # published articles — prose about config, not config
    "public/**",
    "resources/**",
]

Worth saying plainly: 84% of the first run was noise for this repo. That’s not a defect in the tool so much as a fact about pointing a config linter at a content repository. Scoping is a first-class setup step, not an afterthought, and any writeup that skips straight to “install it, it’s great” is doing you a disservice.

The three that were right

The remaining three were in CLAUDE.md, and they’re a different category of finding entirely:

Text
CC-MEM-006  Negative instruction 'Never' without positive alternative
PE-001      Critical keyword 'always' at 47 percent of document
            (40-60 percent is the 'lost in the middle' zone)
PE-003      Weak language 'should' in critical section 'Content Security'

PE-001 is the one worth stopping on. It encodes a real, measured property of long-context attention — that material in the middle of a long document gets attended to less reliably than material at either end — and turns it into a build-checkable rule about where in the file you put your important instruction. That’s not linting syntax. That’s linting instruction design.

We’d written - Cover images sourced from Unsplash (free license) — always credit photographer at bottom of article and buried it at 47% depth. The rule is right. We rewrote it as a direct imperative rather than relying on positional salience.

PE-003 caught External links should use HTTPS inside a section headed Content Security. “Should” in a security rule is an invitation. It’s must now. CC-MEM-006 caught **Never commit secrets** giving no positive alternative — telling an agent what not to do without telling it what to do instead leaves it to improvise the alternative.

None of these break anything today. All of them make the file a worse instruction set than it could be.

On our own plugin

Linting the new plugin repo produced CC-PL-004: plugin.json missing the recommended version field.

Which is interesting, because we’d copied that manifest’s shape from Anthropic’s own official hookify plugin — and hookify doesn’t have a version field either. The recommendation is sound; a marketplace that accumulates plugins needs consumers to know what they installed. It just isn’t universally followed, including by the reference implementations people copy from.

We added it, then locked it with a test so it can’t regress:

Python
self.assertRegex(plugin.get("version", ""), r"^\d+\.\d+\.\d+$")

Step three: what the linter can’t catch

Here’s where the honesty has to come in, because this is the part a tool announcement would skip.

Our own tests caught a bug agnix couldn’t see. The validator skipped _-prefixed files when scanning a directory, but validated a single changed file unconditionally. So the hook blocked on _index.md — a Hugo section index, which legitimately has none of a post’s required keys — while a directory scan correctly ignored it. A blocking hook that rejects _index.md would make the plugin actively hostile on any real Hugo site. agnix validates that your config is well-formed. It has no opinion about whether your logic is right.

Two limits we documented rather than papered over. A correctly-sized cover image of entirely the wrong subject passes gate 3 — dimensions are checkable, subject matter isn’t. And the build gate trusts the exit code, so a build printing errors to stderr while exiting 0 is reported as passing. We wrote both into the repo’s adversarial review rather than pretending the coverage is total.

And the leak. We committed agnix’s raw first-run output as an integrity record — unmodified, so the “before” state couldn’t be quietly improved. The pre-publication scan found it contained absolute paths with the author’s username, about to be published in a public company repo. The property that made the artifact trustworthy — raw, unedited tool output — is exactly the property that made it leak. We sanitised on one documented axis and said so inside the file.

The payoff

Then we pointed the validator at this site. Twenty-eight posts, fifty-six files, four gates:

Text
Front matter:    0 violations
Language parity: 0 violations
Build:           passed
Cover images:    3 violations
  - blog-axon5-cover.jpg:         1200x673
  - blog-flux-operator-cover.jpg: 1200x943
  - blog-mcp-stateless-cover.jpg: 1200x800

CLAUDE.md has said 1200×630 since the blog started. Three covers drifted anyway, and nothing caught it — because the damage doesn’t show on the page. It shows in Open Graph previews, in the card someone sees when the link gets shared in Slack. The place you never look at your own site.

That’s the argument, made on our own repo, by the thing we built to make it. The convention was written down. Writing it down wasn’t enough. It was never going to be enough.

Where this leaves the stack

Three layers, each making the one above it checkable:

  • CLAUDE.md describes the conventions
  • A skill with gates enforces the enforceable subset
  • agnix validates that the skill is well-formed

And a limit that doesn’t go away: agnix validates the form of your agent configuration. ArchUnit validates the substance of your code. Neither one can tell you your instructions say the right things. A linter can confirm CLAUDE.md is well-formed prose in the right shape with its critical keywords in attention-friendly positions. It cannot confirm the conventions are any good.

That judgement is still yours. What’s changed is that everything mechanically checkable beneath it now fails loudly instead of drifting quietly — which is exactly what we said we wanted three months ago, and had not yet done to the file doing the steering.


The plugin is MIT-licensed and installable in two commands:

Text
/plugin marketplace add SaaSForge-s-r-o/claude-skills
/plugin install hugo-blog@claude-skills

The repo includes the raw first-run agnix output, the adversarial review with its two accepted limitations, and a verification record listing what we asserted but had not yet observed in a live session. We’d rather ship stated unknowns than silent assumptions.

Related: Architecture Tests as a Seatbelt Against Agentic Drift · Spring Modulith in Practice

Cover photo by Mick Haupt on Unsplash.

More from the Blog