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 atdocs/adrs/007-foo.mdwhen[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.
A claim the config cannot answer for
There is a third case between “claimed” and “silently skipped”: a file whose
id claims a namespace the config never declares. A team invents
docs/reports/001-....md carrying id: REPORT-001, and no [REPORT] block
exists anywhere.
Such a file is not skipped. It is ingested, counted, listed by ctxgrd list,
and linted – but only under the core rules an undeclared namespace falls
back to (core.frontmatter, core.id, core.id-unique, core.dep-resolved,
core.dep-cycle, core.cross-ref). Every rule that would describe the
document’s shape – required headings, required metadata, allowed values,
minimum document count – is silently absent, and the run still prints ok.
The convention is unenforced and the green summary says otherwise.
cfg.namespace-undeclared closes that gap. It is a warning, so the exit code
does not change, and it fires once per undeclared namespace with the fix in
hand:
warning[cfg.namespace-undeclared]: 1 document claims namespace 'REPORT', which ctxgrd.toml does not declare
--> docs/reports/001-phase-0.md:2:0
help: add a [REPORT] block to ctxgrd.toml, or add 'REPORT' to [ignore].namespaces if the id belongs to another repoThis does not weaken first-touch silence. id: REPORT-001 is a claim of
intent under the same definition every other document uses – it merely names a
namespace nothing answers for. A Hugo page, a design-token file, or a Notion
export carries no such id and stays as silent as before. The warning is bounded
to files already speaking ctxgrd’s <NAMESPACE>-<number> id syntax.
Two situations turn it off entirely. In zero-config mode – no
ctxgrd.toml at all – every namespace is undeclared by definition, and the
fallback is the documented behaviour rather than a gap. Under a scoped run
(--namespace / --pack) ctxgrd reports one slice of the config, not the whole
of it. And a namespace you are staging deliberately, or whose ids belong to a
different repo, opts out with [ignore].namespaces = ["REPORT"] – which
silences the warning while the count stays visible on the summary line and in
--format json, because the documents really are linting under the
zero-config set rather than the rules you would have written.
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. See Linting non-markdown facts with external sources for the mechanics.
A namespace pointed at non-markdown claims nothing, and now says so
The markdown-only boundary is easy to walk into by accident. The obvious first
attempt at gating a non-markdown fact – Prolog clauses, a YAML config, source
code – is a namespace whose paths glob points straight at it:
[STATUTE]
paths = ["src/**", "test/**"]
rules = ["statute.clause-traceability"]The glob matches real files on disk, but the markdown walker skips every one
of them by extension, so the namespace ingests zero documents and the
configured rule never runs against anything. That used to lint clean –
identical to a project with a genuinely well-formed STATUTE namespace.
cfg.paths-skipped closes the gap:
warning[cfg.paths-skipped]: [STATUTE] paths match 12 files the walker skipped, and the namespace ingested no documents
help: to lint non-markdown facts, emit them from a source (`ctxgrd docs sources`) — a namespace cannot claim them directly
note: only .md files are ingested; the skipped files had: .plThis follows the same evidence rule as cfg.namespace-undeclared above: the
warning requires positive evidence that the claim was live – a glob that
matched real files the walker then skipped – so a paths glob matching
nothing on disk (an unpopulated directory, or a typo’d path) stays silent.
Only a claim that fired and came back empty is loud. See
Linting non-markdown facts with external sources
for the mechanism the help: line points to.
Exit codes and machine readability
ctxgrd is driven as often by agents as by people. Every command follows the same exit-code contract:
| Code | Meaning |
|---|---|
0 | No diagnostics – clean. |
1 | Diagnostics reported. |
2 | Kernel 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
- Getting started with ctxgrd – configure your first namespace and run the linter.
- Configuring namespaces –
[<NS>].paths,[ignore], and the full claim model in depth. - Rule packs – built-in namespace bundles that ship pre-configured path claims.
- Linting non-markdown facts with external sources – the supported way to gate facts that aren’t markdown.
- Writing custom rules – the
.md-only rule surface, and where it forks toward a source instead.