---
name: frontmatter-guard
version: 1.1.0
description: |
  Validate and auto-repair YAML frontmatter on brain pages. Catches malformed
  pages before they enter the brain (missing closing ---, nested quotes, slug
  mismatches, null bytes, empty frontmatter, YAML parse failures), explains
  files sync holds instead of importing, and drives the previewed
  `gbrain repair frontmatter` fix. Wraps the `gbrain frontmatter` CLI for
  agent-driven workflows.
triggers:
  - "validate frontmatter"
  - "check frontmatter"
  - "fix frontmatter"
  - "frontmatter audit"
  - "brain lint"
  - "held files"
  - "sync held a file"
tools:
  - exec
mutating: true
---

# Frontmatter Guard Skill

> **Convention:** see `skills/conventions/quality.md` for citation rules; this skill is structural validation, not citation auditing.

## Contract

This skill guarantees:
- Every brain page is scanned against the eight canonical frontmatter validation classes
- Mechanical errors (nested quotes, missing closing `---`, null bytes, slug mismatch) are auto-repairable on demand with `.bak` backups
- Validation logic is shared with `gbrain doctor`'s `frontmatter_integrity` subcheck — single source of truth
- Reports per source (gbrain is multi-source since v0.18.0); never silently audits the wrong root
- Files sync holds are explained from their hold record (code, reason, key, line, fix) and fixed only through a previewed, hash-bound repair the user approved

## Why This Exists

Brain pages pile up over months. Agents write them with malformed frontmatter:
- Missing closing `---` (entity detector bugs)
- Unstructured YAML in meeting pages (ingestion bugs)
- Slug mismatches (path renames not propagated)
- Null bytes (binary corruption from copy-paste accidents)
- Nested double quotes in titles (`title: "Alice "Ace" Example"`)

Without a guard, these accumulate silently until `gbrain sync` chokes or search returns garbage. The guard makes the failure visible at audit time and trivially fixable.

## Write brain files safely (do this first)

- Write pages through `put_page` / `capture` (MCP or `gbrain put`). gbrain serializes frontmatter itself, so the file on disk is always valid YAML.
- A script or agent that writes Markdown files directly must build frontmatter with a YAML serializer (`yaml.dump`, js-yaml `dump`), never by string interpolation. `title: ${title}` breaks as soon as a title contains `: `, `#`, quotes or a newline.
- Check generated content before writing it, without touching disk:

  ```bash
  printf '%s' "$content" | gbrain frontmatter validate --stdin --path notes/2026-10-04-digest.md
  ```

  Exit 1 means fix it first. The default is the strict producer rule: YAML gbrain could still import by quoting a value fails too, because other tools reject it. `--importable` answers the narrower question "would sync hold it?".
- Never write a key gbrain reads for access or identity (`visibility`, `derived_from`, `slug`, `type`, `id`, `source_id`) from untrusted text.

## Held files: read them, then fix them

A file sync cannot import without guessing is **held**: the rest of the source still syncs, a new file has no page yet, and an existing page keeps its last good revision and refuses `put_page` until the file is repaired (do not retry the write). Where holds show up: sync output (`Held <path>: <code> … Next: <command>`), `gbrain sources status <id>` (`--json`: `git_holds.items[]`), doctor `git_held_files`, `get_page` `file_held`, and search hits marked `stale`.

Each hold record carries `code`, `reason`, `key`, `line`, `message` (location only, never the value), `fix` (exact argv) and `docs`. Act on `reason`:

| Hold | What to do |
|------|-----------|
| `invalid_frontmatter` / `yaml_parse` | Preview `gbrain repair frontmatter --source <id>`; usually `needs_review` with the exact line to fix by hand. |
| `invalid_frontmatter` / `needs_interpretation` | Preview with `--include-ambiguous`, show the user each per-file diff, apply only what they approve. |
| `invalid_frontmatter` / `ambiguous_identity_key` or `ambiguous_protected_key` | Never guess. Show the user the line; they decide the one value (`visibility` decides who can read the page). |
| `frontmatter_slug_conflict` | Remove the `slug:` line or move the file; the repair proposes removing it under `--include-ambiguous`. |
| `file_too_large` | Split the file or add it to `sync.exclude`; the limit is fixed. |
| `content_rejected` | The user chose `content_sanity.junk_disposition=reject`; ask before changing it. |

The fix flow (walkthrough with real output: `docs/guides/repair.md#held-files`):

```bash
gbrain sources status <id>                                  # what is held and why
gbrain repair frontmatter --source <id>                     # pass 1: safe quoting only; writes nothing
gbrain repair frontmatter --source <id> --include-ambiguous --diff   # pass 2: every interpretation
gbrain repair frontmatter --source <id> --include-ambiguous --only <path> --apply --expect <hash> --yes
```

**Ask the user before** every `--apply` (it rewrites their files) and before any `--include-ambiguous` apply: each interpretation (folding lines into a title, keeping the later duplicate) is a guess only the user can confirm. Show the diff, then apply exactly the previewed hash. Safe-class changes only quote a value exactly as gbrain already reads it, but they still rewrite files, so they need the same agreement.

A source that a broken file blocked before the upgrade recovers on its next sync; to do it now run `gbrain sync --source <id> --no-pull`.

## Validation classes

| Code | Meaning | Auto-fixable? |
|------|---------|---------------|
| `MISSING_OPEN` | File doesn't start with `---` | No (needs human) |
| `MISSING_CLOSE` | No closing `---` before first heading | Yes |
| `YAML_PARSE` | YAML failed to parse | Sometimes (depends on cause) |
| `SLUG_MISMATCH` | Frontmatter `slug:` differs from path-derived slug | Yes (removes the field) |
| `NULL_BYTES` | Binary corruption (`\x00`) | Yes |
| `NESTED_QUOTES` | `title: "outer "inner" outer"` shape | Yes |
| `NON_STRING_FIELD` | `title`/`type`/`slug` is an unquoted non-string scalar (e.g. `title: 123`, `slug: 2024-06-01`) | No (quote the value) |
| `EMPTY_FRONTMATTER` | Open + close present but nothing between | No (needs human) |

## Phases

### Phase 1: Audit

Run a read-only scan across all registered sources (or one with `--source <id>`).

```bash
gbrain frontmatter audit --json
```

Reports:
- Per-source counts grouped by error code
- Sample of up to 20 affected pages per source
- Total count
- Scan timestamp

Output is JSON; agents parse `errors_by_code` and `per_source` to decide next steps.

### Phase 2: Validate one path

Validate a single file or directory (does not require source registration):

```bash
gbrain frontmatter validate <path> --json
```

Exit code 0 = clean; 1 = errors found. Use this in CI pipelines or pre-commit hooks.

### Phase 3: Fix

When issues are found:

```bash
gbrain frontmatter validate <path> --fix
```

`--fix` backs up every modified file under `~/.gbrain/backups/frontmatter/` before mutating, quotes YAML gbrain reads by quoting (only those lines change), re-validates, and exits 1 when errors remain. Restage fixed files in git (`git add`). `--include-ambiguous` adds interpretations; preview them with `--dry-run` first.

`--dry-run` previews without writing. Use this before applying fixes in batch.

On a managed brain `--fix` in place is refused: use `gbrain repair frontmatter --source <id>` (above), which publishes each repaired file through the coordinated writer.

### Phase 4: Pre-commit hook (optional)

For brain repos that ARE git repos, install the pre-commit hook to block malformed pages from being committed in the first place:

```bash
gbrain frontmatter install-hook [--source <id>]
```

The hook runs one `gbrain frontmatter validate --staged` process over the staged content of `.md`/`.mdx` files (what will be committed, not the working copy). Bypass with `git commit --no-verify`. Installing writes into the user's repository, so ask first. When doctor `frontmatter_hook` reports an older hook, refresh it with `gbrain frontmatter install-hook --force`.

## Trigger words

When the user says any of these, route here:
- "validate frontmatter"
- "check frontmatter"
- "fix frontmatter"
- "frontmatter audit"
- "brain lint"

## Output rules

- Always run `gbrain frontmatter audit --json` first; never assume a brain is clean.
- Surface counts to the user in plain language; do not dump raw JSON.
- For `--fix` operations: state how many files will be modified BEFORE running, then confirm.
- `SLUG_MISMATCH` fixes remove the frontmatter `slug:` field — gbrain derives slug from path. Mention this when the user's title is intentionally renamed.
- Never auto-fix `MISSING_OPEN` or `EMPTY_FRONTMATTER` without explicit user input — these usually mean a human author started a page and didn't finish.

## Chains with

- `gbrain doctor` — the `frontmatter_integrity` subcheck reports the same counts as `audit`; `frontmatter_repairable` counts files `gbrain repair frontmatter` can fix and `git_held_files` lists held files.
- `skills/maintain/SKILL.md` — broader brain health audit; chain after this skill if other classes of issue are suspected.
- `gbrain lint` — overlapping rules for skill-file lint (a CLI command, not a skill); the `frontmatter-*` rule names in lint output come from this skill's validation surface.

## Output Format

Audit summary (terse, agent-friendly):

```
Frontmatter audit — 17 issue(s) across 1 source(s)

[default] /Users/me/brain
  17 issue(s)
    MISSING_CLOSE: 8
    NESTED_QUOTES: 5
    NULL_BYTES: 4
  sample:
    people/jane.md — MISSING_CLOSE
    companies/acme.md — NESTED_QUOTES
    (+ 12 more)

Fix with: gbrain frontmatter validate /Users/me/brain --fix
```

JSON envelope (when `--json` is passed):

```json
{
  "ok": false,
  "total": 17,
  "errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
  "per_source": [
    {
      "source_id": "default",
      "source_path": "/Users/me/brain",
      "total": 17,
      "errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
      "sample": [{ "path": "people/jane.md", "codes": ["MISSING_CLOSE"] }]
    }
  ],
  "scanned_at": "2026-04-25T22:30:00.000Z"
}
```

`gbrain frontmatter validate <path> --json` returns a similar envelope keyed on per-file results instead of per-source.

## Prevention — Writing Valid Frontmatter

**This is the most important section.** Fixing broken frontmatter is good. Not writing broken frontmatter in the first place is better.

### YAML arrays (the historical #1 error source)

```yaml
# Correct: single-quoted YAML flow (canonical form gbrain emits)
tags: ['yc', 'w2025', 'ai']

# Correct: unquoted scalars (fine when values have no special chars)
tags: [yc, w2025, ai]

# Correct: block style
tags:
  - yc
  - w2025

# Tolerated post-v0.37.5.0 but non-canonical: JSON-style double quotes
tags: ["yc", "w2025"]

# Broken: mixed JSON objects and strings (invalid YAML)
tags: [{"name": "sports"}, "posterous"]
```

**Why this used to break:** before v0.37.5.0, the validator counted unescaped `"` characters and flagged any line with 3+. A flow sequence like `tags: ["yc", "w2025"]` has 4 unescaped `"` by design — it's valid YAML, but the dumb counter flagged it anyway. One brain saw 6,981 of these on a single doctor run. v0.37.5.0 parses suspicious values with `js-yaml.safeLoad` before flagging, so JSON-style arrays no longer trigger NESTED_QUOTES.

**Why you should still write the canonical form:** the auto-fix engine (`gbrain frontmatter validate --fix`) and the inferred-frontmatter serializer both emit single-quoted YAML for `tags:` / `aliases:`. Writing the canonical form in new content keeps the source files stylistically consistent and makes diffs against `--fix` runs empty.

**The classic LLM trap:** code like `tags: [${items.map(t => JSON.stringify(t)).join(', ')}]` produces `tags: ["yc", "w2025"]`. Use single quotes with an apostrophe fallback: `tags: [${items.map(t => t.includes("'") ? JSON.stringify(t) : "'" + t + "'").join(', ')}]`. Or use a YAML library that knows how to emit canonical YAML.

### Quoted scalars

```yaml
# Correct: single quotes for values with special chars
title: 'My "Quoted" Title'

# Correct: double quotes when value has apostrophes
title: "Men's Fashion Guide"

# Broken: double quotes wrapping inner double quotes
title: "My "Quoted" Title"
```

### When to quote at all

- **Unquoted** is fine for simple values: `type: person`, `batch: w2025`
- **Quote** when the value contains `: " ' # [ ] { } | > & * ! ? ,` or starts with `@`
- **Single quotes** are the default safe choice
- **Double quotes** only when the value itself contains apostrophes

## When it fails

Follow the [agent operator protocol](../conventions/agent-operator-protocol.md) for any gbrain error `code`, exit code, `[AGENT]` block or notice block. Specific to this skill:

- `gbrain frontmatter validate` exits 1: errors were found (that is the result, not a crash). Parse `errors_by_code` and report counts per source.
- Before a fix pass, state how many files will change and get the user's agreement; the fix writes `.bak` backups, and YAML_PARSE errors are not always auto-repairable.
- `gbrain sync` holds a file (`invalid_frontmatter`, `frontmatter_slug_conflict`, `file_too_large`, `content_rejected`): the sync still succeeded. Read the hold (`gbrain sources status <id>`) and follow "Held files" above.
- `put_page` refuses a page whose newer file is held: repair the file; retrying the write refuses again.

## Anti-Patterns

**Don't auto-fix `MISSING_OPEN` or `EMPTY_FRONTMATTER` without user input.** These usually mean a human author started a page and didn't finish — silently inserting `---` markers around an unfinished draft is wrong.

**Don't use `--fix` to "make doctor green" without reading the audit first.** SLUG_MISMATCH cases are surfaced for manual review specifically because gbrain derives the slug from path. A mismatch usually means the user renamed a file intentionally; auto-removing the slug field is the right outcome only when you've confirmed the rename was deliberate.

**Don't skip the `.bak` backups.** The `.bak` is the safety contract for non-git brain repos. If `.bak` files accumulate after a fix run, that's a feature, not a bug — the user can review the diffs and delete the backups when satisfied.

**Don't run `audit` on a brain where sources aren't registered.** The CLI returns "no registered sources to audit" gracefully, but the migration emits a `skipped: no_sources` phase result. Don't paper over this with a manual path-walk; the right fix is to register the source via `gbrain sources add`.

**Don't install the pre-commit hook on brain dirs outside any git repo.** The install-hook command skips them automatically with a one-line note (a brain that is a subdirectory of a host repo is fine — the hook installs at the host root, scoped to that subdirectory). If you see "skipped, not a git repo" and want validation at write time anyway, use the `audit` command on a cron schedule.
