How ctxgrd decides what to lint

ctxgrd is quiet by default – and this is on purpose. Understanding why requires understanding the single design decision that sets it apart from most frontmatter-walking linters: a file must claim intent before ctxgrd will touch it.

The problem with frontmatter as a signal

A naive linter infers “this is a document I should check” from the presence of a YAML frontmatter block. That inference works in a greenfield repo where ctxgrd is the only tool that uses frontmatter. It breaks everywhere else.

A Hugo site puts frontmatter on every content page, _index.md, and blog post. A design-token system uses a DESIGN.md with version: and palette: keys. An Obsidian vault embeds YAML for its own indexing. Notion exports carry frontmatter on every note. None of these files are records that ctxgrd was configured to lint – but a frontmatter-driven linter would fire on all of them, producing diagnostics that the user has to enumerate into an [ignore] exclusion list just to make the noise stop.

The deeper problem with negative declarations is that they compound. You exclude **/CHANGELOG.md today; next quarter your team adopts a new tool that writes frontmatter to **/DESIGN.md; the quarter after that, something else. The list grows with every new tool in the repo. And a broad [ignore] entry silently swallows real documents that land under the same path – so you may be leaving genuine ADRs or runbooks unlinted with no warning.

Claims of intent

ctxgrd resolves this with a positive declaration instead of a negative one. Rather than asking “what should I skip?”, it asks “what has the user explicitly said belongs to ctxgrd?”

A file is a candidate for linting iff it makes one of two claims of intent:

  • Id-claim. The frontmatter contains id: <NAMESPACE>-<NUMBER> and <NAMESPACE> matches a configured namespace. Example: id: ADR-007.
  • Path-claim. The file’s path matches one of the globs in [<NAMESPACE>].paths. Example: a file at docs/adrs/007-foo.md when [ADR].paths = ["docs/adrs/**"] is configured.

Files that satisfy neither signal are silently skipped – no diagnostic, no hint, no noise. It does not matter whether they have frontmatter. ctxgrd has not been asked to lint them, so it does not.

This is why running ctxgrd on a fresh repo with no configuration produces exactly zero output. It is not broken; it is waiting for intent.

Why “first touch is silent” is load-bearing

The silence on first touch is not an oversight to work around – it is the property that makes ctxgrd safe to install in any repo without a cleanup sprint.

When ctxgrd fires on files that were never meant to be ctxgrd documents, the user is forced to choose between noisy output they cannot action and an [ignore] list that grows indefinitely and may suppress real documents. Neither is acceptable. Silent-by-default means ctxgrd can coexist with Hugo, design tokens, and Notion exports from day one, and diagnostics, when they do appear, are unconditionally actionable – they can only fire on files the user has explicitly claimed.

The help text for core.id and core.frontmatter reflects this: it no longer carries the “or add to [ignore].patterns if this file isn’t a ctxgrd document” escape clause that older versions needed. Under intent-based classification, a diagnostic implies the file is a ctxgrd document by intent, so the escape is never needed.

Two ways to claim, two purposes

Id-claims and path-claims serve different needs and complement each other.

An id-claim works file by file. It is the natural fit for repos where documents are scattered across directories or already follow a naming convention that ctxgrd recognises. It also works without any ctxgrd.toml change – just add id: ADR-001 to a file and it enters the linting scope immediately.

A path-claim works directory by directory. It is the right shape when a team stores all documents of a type together: docs/adrs/, docs/runbooks/, or any glob that covers the location. With path-claims, a new document dropped into the right directory is immediately linted without requiring any frontmatter id. The ctxgrd init command pre-fills [<NS>].paths automatically when it detects conventional directories – so most users get path-claims without writing a line of config.

When a file matches both an id-claim and a path-claim, the id-claim wins for namespace classification. When a file matches two different namespaces’ path globs and has no id to resolve the ambiguity, ctxgrd emits a cfg.path-conflict configuration error naming both namespaces and the file path, rather than silently picking one. Overlap in path globs is almost always a mistake; the error surfaces it immediately.

Markdown only, and why

ctxgrd lints only .md files with YAML frontmatter and <NAMESPACE>-<NUMBER> IDs. It does not lint Turtle files, YAML, JSON, source code, or any other format.

This boundary exists for two reasons. The first is focus: structured markdown records – ADRs, PRDs, runbooks, post-mortems – share a common shape and a common lifecycle (status fields, dependency links, required headings). A linter built around that shape produces tight, actionable diagnostics. Expanding to other formats would dilute that focus and require generalising the rule model.

The second reason is determinism. Same input, same output, same exit code – the invariant that makes ctxgrd safe in CI and in agent loops. Non-markdown formats would introduce format-specific parse ambiguities and edge cases that are difficult to contain. The supported escape hatch for teams that do want to lint other formats is an external source script: a small program that reads the non-markdown format and emits document envelopes that ctxgrd’s rule engine can process. The core remains markdown-only; the extension surface is explicit and contained.

Exit codes and machine readability

ctxgrd is driven as often by agents as by people. Every command follows the same exit-code contract:

CodeMeaning
0No diagnostics – clean.
1Diagnostics reported.
2Kernel or configuration error.

This means a CI step or an agent loop can branch on outcome without parsing text. --format json streams diagnostics as structured objects on stdout. Diagnostic hints and progress messages go to stderr, keeping stdout a clean pipe target.

The determinism of intent-based classification is what makes these exit codes trustworthy. Because ctxgrd only lints files you have explicitly claimed, a 0 means “every document you declared clean is genuinely clean” – not “nothing fired because ctxgrd quietly skipped half your repo.”

One file, one namespace

A file is claimed by exactly one namespace. That single-owner rule keeps rules deterministic: each namespace has its own rule list, required headings, and allowed status values, and mixing two namespaces’ rules against a single file would produce undefined behavior.

Cross-file rules – for example, a rule asserting that a CLAUDE.md links to the entry guide – fire from the namespace that owns one side and read the other file from disk. They do not introduce a second namespace claim on the far file.

The done-signal question

One consequence of pooling documents by type is that ctxgrd status --exit-code on the whole project asks “is the entire repo done?” – which is almost never the right question when driving a single feature. The --lineage flag narrows the scope to one feature’s dependency graph. See Polling a feature done-signal in an agent loop for the mechanics.

Further reading