mdformat (gfm-only) silently destroys SKILL.md YAML frontmatter #1

Closed
opened 2026-08-13 18:10:21 +03:00 by issuer-agent · 4 comments

Why: Running uvx --with mdformat-gfm mdformat --wrap 80 <dir> over a Claude Code skill directory rewrites SKILL.md's YAML frontmatter into body text. The opening/closing --- delimiters become a ______… thematic break, and the name:/description: keys collapse into a single ## name: … description: … ATX heading. Consequence: the skill has no name and no description, so it cannot be routed to or loaded at all. It fails silently — the file still lints clean-ish and looks plausible; nothing errors.

Reproduced this session on oleks/claude-plugin-cicd-insights, skills/woodpecker-trigger-discipline/SKILL.md. Detected only because a subsequent markdownlint run reported MD041 first-line-heading with context "______________…", which prompted a re-read of the file head.

Fix: the invocation needs --with mdformat-frontmatter in addition to --with mdformat-gfm. Verified: with both plugins the file round-trips byte-identically and frontmatter is preserved. mdformat-gfm alone protects tables but NOT frontmatter — these are two separate hazards and the well-known one (tables) masks the other.

Where it matters: any documented/instructed reformat step that touches skills/**/SKILL.md or agent definition files with frontmatter. The -gfm-only invocation is in active circulation as the recommended command.

Acceptance:

  • Any documented mdformat invocation for skill/agent markdown includes --with mdformat-frontmatter
  • skills/woodpecker-trigger-discipline/SKILL.md frontmatter verified intact
**Why**: Running `uvx --with mdformat-gfm mdformat --wrap 80 <dir>` over a Claude Code skill directory rewrites `SKILL.md`'s YAML frontmatter into body text. The opening/closing `---` delimiters become a `______…` thematic break, and the `name:`/`description:` keys collapse into a single `## name: … description: …` ATX heading. Consequence: the skill has no `name` and no `description`, so it cannot be routed to or loaded at all. It fails silently — the file still lints clean-ish and looks plausible; nothing errors. Reproduced this session on `oleks/claude-plugin-cicd-insights`, `skills/woodpecker-trigger-discipline/SKILL.md`. Detected only because a subsequent markdownlint run reported `MD041 first-line-heading` with context `"______________…"`, which prompted a re-read of the file head. Fix: the invocation needs `--with mdformat-frontmatter` in addition to `--with mdformat-gfm`. Verified: with both plugins the file round-trips byte-identically and frontmatter is preserved. `mdformat-gfm` alone protects tables but NOT frontmatter — these are two separate hazards and the well-known one (tables) masks the other. Where it matters: any documented/instructed reformat step that touches `skills/**/SKILL.md` or agent definition files with frontmatter. The `-gfm`-only invocation is in active circulation as the recommended command. **Acceptance**: - [ ] Any documented mdformat invocation for skill/agent markdown includes `--with mdformat-frontmatter` - [ ] `skills/woodpecker-trigger-discipline/SKILL.md` frontmatter verified intact
Owner

Root-cause follow-up: where the bad invocation actually comes from.

Searched for the source of the -gfm-only recipe so it could be fixed rather than just documented. Findings:

  • No file in ~/projects/claude-plugins references mdformat at all — not in any plugin, skill, command, or hook script.
  • The global pre-push hooks do not run mdformat either. They run markdownlint, which is what detects the damage (MD041 with context "______…") but never causes it. So the hook is not the propagation path.
  • The truncated command was propagating through session memory: project_ci-lint-factbooks-premise-was-wrong-twice.md recorded uvx --with mdformat-gfm mdformat --wrap 80 as the fix for the tables gotcha, with no mention of frontmatter. That file has now been corrected in place with an explicit warning, and the memory index carries the correction on its pointer line.

Why this class of mistake is sticky: the tables gotcha is well known, so --with mdformat-gfm looks like the complete, informed invocation. Knowing about the first hazard is exactly what makes the truncated command feel correct — the fix for one silent corruption reads as protection against all of them.

Residual risk this issue still tracks: nothing mechanical prevents a future session from running the two-plugin-short command. Options, if worth pursuing:

  1. A pre-push guard that fails when a tracked SKILL.md or agent .md loses its frontmatter — cheap, deterministic, and catches the damage at the only point where it matters. This is the strongest option: it does not depend on anyone remembering the right flags.
  2. Documenting the full invocation in the workspace CLAUDE.md conventions section, next to the existing pre-push auto-format note.

Option 1 is the real fix; option 2 only helps whoever reads it. Not implementing either here without direction, since both are outside this plugin.

**Root-cause follow-up: where the bad invocation actually comes from.** Searched for the source of the `-gfm`-only recipe so it could be fixed rather than just documented. Findings: - **No file in `~/projects/claude-plugins` references `mdformat` at all** — not in any plugin, skill, command, or hook script. - **The global pre-push hooks do not run `mdformat`** either. They run `markdownlint`, which is what *detects* the damage (`MD041` with context `"______…"`) but never causes it. So the hook is not the propagation path. - The truncated command was propagating through **session memory**: `project_ci-lint-factbooks-premise-was-wrong-twice.md` recorded `uvx --with mdformat-gfm mdformat --wrap 80` as the fix for the tables gotcha, with no mention of frontmatter. That file has now been corrected in place with an explicit warning, and the memory index carries the correction on its pointer line. **Why this class of mistake is sticky:** the tables gotcha is well known, so `--with mdformat-gfm` *looks* like the complete, informed invocation. Knowing about the first hazard is exactly what makes the truncated command feel correct — the fix for one silent corruption reads as protection against all of them. **Residual risk this issue still tracks:** nothing mechanical prevents a future session from running the two-plugin-short command. Options, if worth pursuing: 1. A pre-push guard that fails when a tracked `SKILL.md` or agent `.md` loses its frontmatter — cheap, deterministic, and catches the damage at the only point where it matters. This is the strongest option: it does not depend on anyone remembering the right flags. 2. Documenting the full invocation in the workspace `CLAUDE.md` conventions section, next to the existing pre-push auto-format note. Option 1 is the real fix; option 2 only helps whoever reads it. Not implementing either here without direction, since both are outside this plugin.
Owner

Documentation fix landed; the mechanical guard is still open, and that is the part worth deciding on.

Two propagation paths have now been closed:

  1. Session memoryproject_ci-lint-factbooks-premise-was-wrong-twice.md recorded the -gfm-only recipe as the fix for the tables gotcha, with no mention of frontmatter. That is what kept feeding the truncated command into new sessions. Corrected in place with an explicit warning, and flagged on the memory index line so a reader scanning the index cannot pick up the bad form.

  2. Workspace conventions~/projects/claude-plugins/CLAUDE.md now carries the full invocation next to the existing pre-push auto-format bullet, with the mechanism spelled out: the tables warning masks the frontmatter one, and knowing about -gfm is precisely what makes the two-plugin-short command feel informed. Applies to every plugin in the workspace, since they all ship frontmatter-bearing SKILL.md / agents/*.md.

Also added there: the changed-files-only lint behaviour that produced oleks/claude-plugin-cicd-insights#3, since the two hazards bite in the same moment (hand-reformatting a file).

Still open — the real fix. Neither of the above prevents anything; they only help whoever reads them. The mechanical answer is a pre-push guard that fails when a tracked SKILL.md or agents/*.md loses its frontmatter — cheap, deterministic, and it catches the damage at the only point that matters, regardless of which command produced it. It does not depend on anyone remembering flags.

That guard belongs in the global hooks (git-global-hooks), which are NixOS-managed and need a deploy, so it is out of scope for this repo and I have not implemented it. Leaving this issue open against that decision rather than closing on the docs fix — a documentation-only resolution to a silent-corruption bug is exactly the kind of close that looks done and is not.

**Documentation fix landed; the mechanical guard is still open, and that is the part worth deciding on.** Two propagation paths have now been closed: 1. **Session memory** — `project_ci-lint-factbooks-premise-was-wrong-twice.md` recorded the `-gfm`-only recipe as *the* fix for the tables gotcha, with no mention of frontmatter. That is what kept feeding the truncated command into new sessions. Corrected in place with an explicit warning, and flagged on the memory index line so a reader scanning the index cannot pick up the bad form. 2. **Workspace conventions** — `~/projects/claude-plugins/CLAUDE.md` now carries the full invocation next to the existing pre-push auto-format bullet, with the mechanism spelled out: the tables warning *masks* the frontmatter one, and knowing about `-gfm` is precisely what makes the two-plugin-short command feel informed. Applies to every plugin in the workspace, since they all ship frontmatter-bearing `SKILL.md` / `agents/*.md`. Also added there: the changed-files-only lint behaviour that produced oleks/claude-plugin-cicd-insights#3, since the two hazards bite in the same moment (hand-reformatting a file). **Still open — the real fix.** Neither of the above prevents anything; they only help whoever reads them. The mechanical answer is a pre-push guard that fails when a tracked `SKILL.md` or `agents/*.md` loses its frontmatter — cheap, deterministic, and it catches the damage at the only point that matters, regardless of which command produced it. It does not depend on anyone remembering flags. That guard belongs in the global hooks (`git-global-hooks`), which are NixOS-managed and need a deploy, so it is out of scope for this repo and I have not implemented it. Leaving this issue open against that decision rather than closing on the docs fix — a documentation-only resolution to a silent-corruption bug is exactly the kind of close that looks done and is not.
Owner

The guard already exists. It has never run.

Asked to implement this as a claudecode-linter rule, I checked whether one was needed first. It is not — claudecode-linter already ships skill-md/valid-frontmatter and agent-md/valid-frontmatter, and they catch exactly this corruption. Verified against a purpose-built fixture:

skills/broken/SKILL.md
  error  File does not start with '---' frontmatter delimiter   skill-md/valid-frontmatter
agents/broken.md
  error  File does not start with '---' frontmatter delimiter   agent-md/valid-frontmatter
exit=1

Correct rule, non-zero exit, both file types covered. Writing a new one would have duplicated it.

So why did a corrupted SKILL.md get committed and pushed with the detector sitting in the toolchain? The pre-push hook guards the whole slot on the binary being present:

if which("claudecode-linter"):
    ...
    run_lint("CCL", "claudecode-linter", ["claudecode-linter", "--quiet", "."])

which claudecode-linter returns nothing on emmett. It is not on PATH, appears nowhere in ~/projects/servers/emmett/**/*.nix, and the linter repo ships no Nix expression (it is an npm package with a dist/). Because the guard is if which(...), its absence produces no warning and no PASS token — indistinguishable from having run and passed.

The evidence was in plain sight all session: every push printed MDL ✅, one printed MDL ✅ BIO ✅ CPV ✅ TAG ⚠. No CCL token in any of them. A missing token is the only signal, and only if you know to look for it.

This is the third instance of one shape in this work — a check that silently no-ops and is therefore indistinguishable from a passing check. The other two were a paginated API read that looks complete, and a routing test run against a plugin the test subject could not see.

Filed as oleks/emmett#387 with both halves: install claudecode-linter declaratively on emmett, and make the skip visible (a CCL ⏭ token or a stderr notice) so a future PATH regression cannot silently disarm it again. That second half matters independently — it is the difference between a guard that is currently off and a guard that can turn itself off unnoticed.

Keeping this issue open until emmett#387 lands, since the corruption remains unguarded across all 30 plugin repos in the workspace until then. Nothing further to do in this repo: the documentation fixes are in, and the mechanical fix is neither a new rule nor located here.

**The guard already exists. It has never run.** Asked to implement this as a `claudecode-linter` rule, I checked whether one was needed first. It is not — `claudecode-linter` already ships `skill-md/valid-frontmatter` and `agent-md/valid-frontmatter`, and they catch exactly this corruption. Verified against a purpose-built fixture: ``` skills/broken/SKILL.md error File does not start with '---' frontmatter delimiter skill-md/valid-frontmatter agents/broken.md error File does not start with '---' frontmatter delimiter agent-md/valid-frontmatter exit=1 ``` Correct rule, non-zero exit, both file types covered. Writing a new one would have duplicated it. **So why did a corrupted `SKILL.md` get committed and pushed with the detector sitting in the toolchain?** The pre-push hook guards the whole slot on the binary being present: ```python if which("claudecode-linter"): ... run_lint("CCL", "claudecode-linter", ["claudecode-linter", "--quiet", "."]) ``` `which claudecode-linter` returns nothing on emmett. It is not on PATH, appears nowhere in `~/projects/servers/emmett/**/*.nix`, and the linter repo ships no Nix expression (it is an npm package with a `dist/`). Because the guard is `if which(...)`, its absence produces **no warning and no PASS token** — indistinguishable from having run and passed. The evidence was in plain sight all session: every push printed `MDL ✅`, one printed `MDL ✅ BIO ✅ CPV ✅ TAG ⚠`. No `CCL` token in any of them. A missing token is the only signal, and only if you know to look for it. This is the third instance of one shape in this work — a check that silently no-ops and is therefore indistinguishable from a passing check. The other two were a paginated API read that looks complete, and a routing test run against a plugin the test subject could not see. **Filed as [oleks/emmett#387](https://git.oleks.space/oleks/emmett/issues/387)** with both halves: install `claudecode-linter` declaratively on emmett, and make the skip visible (a `CCL ⏭` token or a stderr notice) so a future PATH regression cannot silently disarm it again. That second half matters independently — it is the difference between a guard that is currently off and a guard that can turn itself off unnoticed. Keeping this issue open until emmett#387 lands, since the corruption remains unguarded across all 30 plugin repos in the workspace until then. Nothing further to do in this repo: the documentation fixes are in, and the mechanical fix is neither a new rule nor located here.
Owner

Both acceptance boxes met, and the reason not to close this is now gone.

The standing objection (recorded in the handoff) was: "Did not close #1 on the documentation fix. A docs-only close for a silent-corruption bug looks done and is not." That was right. It no longer applies — this is not a docs-only close.

☑ Any documented mdformat invocation includes --with mdformat-frontmatter

Swept the trees where such an invocation could circulate:

location result
~/projects/claude-plugins/** (all file types) only CLAUDE.md §conventions, which mandates both plugins
cicd-insights/HANDOFF.md correct, both plugins
~/projects/servers/emmett/** no mdformat reference at all
~/.claude/CLAUDE.md, settings.json none
nixos/git-hooks/** none

That last row is the one that matters most and I checked it specifically: no automated hook runs mdformat. Had the pre-push or pre-commit formatter chain invoked it -gfm-only, every push would have been silently corrupting frontmatter. It doesn't.

The single remaining mdformat-gfm-without-frontmatter string in the tree is prose describing the hazard ("The well-known half of this is mdformat-gfm — without it, every GFM table…"), not an invocation.

skills/woodpecker-trigger-discipline/SKILL.md frontmatter intact

Verified by byte-diff against the installed plugin copy at ~/.claude/plugins/cache/oleks-local/cicd-insights/1.4.6/: identical, --- delimiters and name:/description: present. Independently confirmed live — the skill routes correctly on all three SC-003 phrasings (oleks/claude-plugin-cicd-insights#2).

The part that actually closes this

A recurrence can no longer be silent. oleks/emmett#387 is deployed and verified, and the detector was proven to block, not merely report:

CCL ❌ (claudecode-linter):
  error  File does not start with '---' frontmatter delimiter  skill-md/valid-frontmatter
1 error
error: failed to push some refs

That test deliberately used frontmatter-stripped-but-otherwise-valid markdown (H1 first line, all lines < 80 chars), so markdownlint had nothing to say. The first attempt at the probe reproduced this issue's original discovery exactly — markdownlint MD041 with context "______…" firing first — which is luck, not a guard. The second attempt isolated CCL as the blocker.

So the corruption is now caught by the check built for it, on all ~30 plugin repos, at push time. Closing.

Related follow-up: oleks/emmett#389 (the same silent-skip shape still applies to the other lint slots).

**Both acceptance boxes met, and the reason not to close this is now gone.** The standing objection (recorded in the handoff) was: *"Did not close #1 on the documentation fix. A docs-only close for a silent-corruption bug looks done and is not."* That was right. It no longer applies — this is not a docs-only close. ### ☑ Any documented mdformat invocation includes `--with mdformat-frontmatter` Swept the trees where such an invocation could circulate: | location | result | | --- | --- | | `~/projects/claude-plugins/**` (all file types) | only `CLAUDE.md` §conventions, which **mandates both plugins** | | `cicd-insights/HANDOFF.md` | correct, both plugins | | `~/projects/servers/emmett/**` | **no mdformat reference at all** | | `~/.claude/CLAUDE.md`, `settings.json` | none | | `nixos/git-hooks/**` | **none** | That last row is the one that matters most and I checked it specifically: **no automated hook runs mdformat.** Had the pre-push or pre-commit formatter chain invoked it `-gfm`-only, every push would have been silently corrupting frontmatter. It doesn't. The single remaining `mdformat-gfm`-without-`frontmatter` string in the tree is prose *describing the hazard* (`"The well-known half of this is mdformat-gfm — without it, every GFM table…"`), not an invocation. ### ☑ `skills/woodpecker-trigger-discipline/SKILL.md` frontmatter intact Verified by byte-diff against the installed plugin copy at `~/.claude/plugins/cache/oleks-local/cicd-insights/1.4.6/`: **identical**, `---` delimiters and `name:`/`description:` present. Independently confirmed live — the skill routes correctly on all three SC-003 phrasings (oleks/claude-plugin-cicd-insights#2). ### The part that actually closes this A recurrence can no longer be silent. oleks/emmett#387 is deployed and verified, and the detector was proven to **block**, not merely report: ``` CCL ❌ (claudecode-linter): error File does not start with '---' frontmatter delimiter skill-md/valid-frontmatter 1 error error: failed to push some refs ``` That test deliberately used frontmatter-stripped-but-otherwise-valid markdown (H1 first line, all lines < 80 chars), so markdownlint had nothing to say. The first attempt at the probe reproduced this issue's *original* discovery exactly — markdownlint `MD041` with context `"______…"` firing first — which is luck, not a guard. The second attempt isolated CCL as the blocker. So the corruption is now caught by the check built for it, on all ~30 plugin repos, at push time. Closing. Related follow-up: oleks/emmett#389 (the same silent-skip shape still applies to the other lint slots).
oleks added the agent/claim:oleks/4bdde801agent/wip labels 2026-08-13 23:00:14 +03:00
oleks closed this issue 2026-08-13 23:00:18 +03:00
oleks removed the agent/wipagent/claim:oleks/4bdde801 labels 2026-08-13 23:27:38 +03:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: oleks/claude-plugin-cicd-insights#1