Skip to content

Adopt in a repo

This walkthrough takes a fresh repository from zero to full jk-standards gating. If you already have pre-commit, jk-standards.yaml, and a drift map, jump straight to the Quickstart instead.

Decide where your docs live. Common shapes:

  • docs/ for a plain markdown repo
  • site/src/content/docs/ for an Astro/Starlight site
  • Both, if you have some raw markdown and a rendered site

Every file under a doc root will need class: frontmatter. If you have long-frozen historical docs you don’t want to classify, list their containing directory under exempt_dirs.

Create jk-standards.yaml at the repo root. The toolkit dogfoods itself, so its own config is the reference example:

version: 1

doc_roots:
  - path: docs
    extensions: [".md"]
  - path: site/src/content/docs
    extensions: [".mdx"]
  # The governed conventions layer: normative C++ standards a consuming repo
  # adopts (cpp-language-standard, msvc-portability, warning-flags). Registered
  # as a doc_root so its `class: gated` docs are actually swept by doc-taxonomy,
  # status-prose, and count-drift — without this entry the gating is cosmetic.
  - path: conventions
    extensions: [".md"]

taxonomy:
  # `plan` is the delivery-programme class: ledger-adjacent plans, evidence,
  # and decision records under docs/plans/. They are governed by the `ledger`
  # check (structure, DoD, evidence) rather than the drift map, so the class
  # is exempted from doc-completeness below. Ledgers themselves stay `gated`
  # per the ledger standard and carry an exact cannot_drift entry each.
  classes: [generated, gated, archived, plan]
  # ARCHITECTURE.md lives at the repo root, outside the doc_roots, so it is not
  # swept by iter_docs. List it here so the doc-taxonomy check still governs its
  # lifecycle class — the repo's own architecture exemplar must carry class:gated
  # like any other gated doc.
  extra_files:
    - ARCHITECTURE.md

file_line_refs:
  source_roots:
    - path: src
      extensions: [".py"]

# Inventory facts about the toolkit itself (how many checks, hooks, skills)
# must not be restated as numerals in the docs — they change as checks land.
# region:count-triggers
count_drift:
  triggers:
    - 'checks?\s+(?:in\s+total|total)'
    - 'hooks?\s+(?:in\s+total|total)'
    - 'skills?\s+(?:in\s+total|total)'
    - 'checks?\s+ship(?:s|ped)?'
    - 'hooks?\s+ship(?:s|ped)?'
    - 'skills?\s+ship(?:s|ped)?'
    - 'fields?\s+(?:in|of)\s+(?:the\s+)?config'
    - '(?:checks?|skills?|hooks?|fields?)\s+are\s+available'
    - '(?:checks?|skills?|hooks?|fields?)\s+are\s+registered'
# endregion:count-triggers

drift_map: .github/docs-drift-map.yml

doc_completeness:
  # `archived` docs are frozen; `plan` docs (see taxonomy above) are the
  # delivery programme's own working records, governed by the `ledger` check.
  exempt_classes: [archived, plan]

behavioral_claims:
  sources:
    - type: pytest
      path: tests

# Site JSON fixtures projected from Python source. `generated-freshness`
# runs each command, diffs against the tracked file, and restores the
# snapshot — so a source change without a regenerated fixture fails CI.
#
# coverage.json is intentionally NOT gated here: coverage numbers are
# environment-dependent (Python version, platform branches) and would drift
# between local dev and CI even with no source change. The emitter still
# runs at site-build time (site/package.json prebuild) so the site always
# shows current coverage; it just isn't diff-gated.
# region:generated-fixtures
generated:
  - doc: site/src/generated/checks.json
    command: jk-standards emit checks
  - doc: site/src/generated/config-schema.json
    command: jk-standards emit config-schema
  - doc: site/src/generated/skills.json
    command: jk-standards emit skills
  - doc: site/src/generated/doc-coverage.json
    command: jk-standards emit doc-coverage
# endregion:generated-fixtures

# The .mdx reference pages slice source via <CodeSnippet file=… region=… />;
# snippet-regions verifies every such reference resolves to a real
# region:<name> marker in the named file. This repo references regions only
# through CodeSnippet (which names its own file=), so no source_roots are
# declared — prose scanning stays off and every CodeSnippet target is checked.
snippet_regions:
  doc_roots:
    - path: docs
      extensions: [".md"]
    - path: site/src/content/docs
      extensions: [".mdx"]

# The two directed boundaries ARCHITECTURE.md lists as invariants. Dependencies
# flow one way: the CLI and emitters import the check registry, so a check must
# never import back into either — that would invert the dependency and create a
# cycle. Each rule greps every .py under checks/ for the module-path reference;
# a genuinely-necessary crossing is waived in place with `# boundary-ok: <why>`.
# region:boundaries-config
boundaries:
  rules:
    - name: checks-no-cli
      from: src/jk_standards/checks
      extensions: [".py"]
      forbid: 'jk_standards\.cli\b|from jk_standards import [^\n]*\bcli\b'
      hint: "a check must not import the CLI — the CLI depends on checks, not the reverse"
    - name: checks-no-emit
      from: src/jk_standards/checks
      extensions: [".py"]
      forbid: 'jk_standards\.emit\b|from jk_standards import [^\n]*\bemit\b'
      hint: "a check must not import the emitter — emit depends on checks, not the reverse"
# endregion:boundaries-config

# doc-coverage inverts doc-completeness: the ast enumerator walks these Python
# source roots listing public documentable units, and scans doc_scopes for the
# word-boundary "mention" OR-signal. A module fails only if EVERY unit in it is
# undocumented (no docstring, no drift-map glob match, no doc mention); a
# top-of-file `# doc-coverage-ok: <reason>` marker waives a module.
# region:doc-coverage-config
doc_coverage:
  source_roots:
    - path: src/jk_standards
      extensions: [".py"]
  doc_scopes: [docs, site/src/content/docs, conventions]
# endregion:doc-coverage-config

# import-cycle builds the module-level import graph for each listed package,
# finds every strongly-connected component of >1 module (a runtime import
# cycle), and reports it at file:line. Skip-when-unconfigured like boundaries:
# with no packages listed the check is a no-op. The toolkit gates its own
# source tree; its one real cycle (the checks re-export hub) is waived in place
# with `# import-cycle-ok:` at src/jk_standards/checks/__init__.py, so a NEW
# cycle is what turns this red.
# region:import-cycle-config
import_cycle:
  packages:
    - src/jk_standards
# endregion:import-cycle-config

# workflow-concurrency: a concurrency group must scope itself by ref, or be
# named here as a lock that is deliberately repo-wide. `pages` is the only one:
# publish-site.yml serialises real GitHub Pages deployments, which genuinely
# contend for a single resource. deploy-site.yml is absent because its group
# already carries `github.ref` for the build-only smoke path — the scoping that
# stops concurrent pull requests from cancelling each other's jobs.
# region:workflow-concurrency-config
workflow_concurrency:
  global_locks:
    - pages
# endregion:workflow-concurrency-config

# release-pins: every released version is tagged, and every pin naming this
# repo resolves to a real tag. `untagged_versions` records the three releases
# that shipped a changelog section but never got a tag — recovering those tags
# retroactively would date them wrong, so they are declared instead and the
# check ratchets on every release after them. The MIGRATION notes are excluded
# because their pins record what those projects actually adopted at the time;
# rewriting them would falsify the record.
# region:release-pins-config
release_pins:
  repo: JimAKennedy/jk-standards
  untagged_versions: ["0.2.0", "0.4.0", "0.7.0"]
  exclude:
    - MIGRATION-poly.md
    - MIGRATION-nfr-review.md
# endregion:release-pins-config

# LLM-judged skill evaluation (evals/harness; `make eval`). Thresholds start
# generous with the ratchet time-boxed: revisit after two releases' score
# distributions (decision recorded in docs/plans/skill-evals/M002-decisions.md).
# region:skill-evals-config
skill_evals:
  agent_model: claude-sonnet-5
  judge_model: claude-sonnet-5
  runs: 3
  default_threshold: 0.6
  # 8000: claude-sonnet-5 emits a thinking block before its text block, and
  # on design-shaped tasks the thinking alone can exceed 4000 tokens — a
  # cap it exhausts yields the runner's no-output sentinel and a zero score.
  max_output_tokens: 8000
  max_cases: 40
# endregion:skill-evals-config
jk-standards' own config — the toolkit gates itself with itself.

Field-by-field detail is in the Configuration reference.

Frontmatter for every file under the doc roots:

---
class: gated
title: My doc
---
# ...

The three canonical classes:

  • gated — living, current-state doc. Linted by status-prose, file-line-refs, count-drift, and behavioral-claims.
  • archived — frozen dated record. Exempt from all linting; the content is a historical artifact.
  • generated — machine-produced. Freshness-checked against its generator command, but not prose-linted.

Run jk-standards doc-taxonomy — it lists every doc missing a class or carrying an unknown one.

Add jk-standards to your .pre-commit-config.yaml, pinned to a release tag. Every shipped hook is defined once in .pre-commit-hooks.yaml — this is the exact definition your pre-commit install will resolve when you enable doc-taxonomy:

- id: doc-taxonomy
  name: docs carry a lifecycle class
  entry: jk-standards doc-taxonomy
  language: python
  types_or: [markdown, mdx]
  pass_filenames: false
One shipped hook — consume from a pinned tag.

Enable the ones that fit your repo:

- repo: https://github.com/JimAKennedy/jk-standards
rev: v0.13.0
hooks:
- id: doc-taxonomy
- id: status-prose
- id: file-line-refs
# opt in when you have inventory nouns to gate:
# - id: count-drift
# opt in when you have a test suite to cite:
# - id: behavioral-claims

Then pre-commit install and pre-commit run --all-files to catch existing violations.

doc-drift needs a git base ref (the PR merge base) and full commit history, so it lives in CI rather than pre-commit. Consume the shipped reusable workflow:

.github/workflows/docs.yml
name: docs
on: [pull_request, push]
jobs:
discipline:
uses: JimAKennedy/jk-standards/.github/workflows/doc-discipline.yml@v0.13.0
with:
toolkit-ref: v0.1.0 # pin the same version you used above
config: jk-standards.yaml # path in your repo

The workflow checks out the PR with fetch-depth: 0, installs jk-standards at the pinned ref, and runs jk-standards all. First-run failures name the exact drift; fix by either updating the doc or adding a Docs-Not-Affected: <reason> trailer to a commit in the range.

A companion reusable workflow, pre-commit.yml, runs your pre-commit hooks in CI from the same shape of caller. Reach for it when a repo wants a CI gate on hooks it cannot run on pre-commit.ci — pass the optional local-config input to also run a second, self-referential config:

.github/workflows/pre-commit.yml
name: pre-commit
on: [pull_request, push]
jobs:
pre-commit:
uses: JimAKennedy/jk-standards/.github/workflows/pre-commit.yml@v0.13.0
with:
config: .pre-commit-config.yaml # path in your repo
local-config: .pre-commit-config.local.yaml # optional; empty = skip

Step 6 (optional) — Enable inventory gating

Section titled “Step 6 (optional) — Enable inventory gating”

If your prose says things like “N presets ship” or “N chapters in the guide”, configure count_drift.triggers and interpolate the number from a generated JSON:

count_drift:
triggers:
- 'factory\s+presets?'
- 'chapters?\s+in\s+total'
generated:
- doc: docs/counts.json
command: node scripts/emit-counts.mjs

Then reference it in prose as {counts.presets} rather than a numeral — the count-drift check refuses numerals adjacent to trigger phrases, but leaves the template literal alone.

Step 7 (optional) — Enable behavioral claims

Section titled “Step 7 (optional) — Enable behavioral claims”

If you want prose to cite tests, configure a test-index source:

behavioral_claims:
sources:
- type: pytest # gtest | js | pytest
path: tests

Then in prose, add a verified: marker after each claim. The marker wraps a key from the scraped test index in square brackets — pytest keys look like test_module::test_name, gtest keys look like Suite.TestName. Unresolved citations fail; the ⚠ unverified marker is allowed but counted as an honest-state metric.

The Checks reference uses citations like these throughout — every claim about the toolkit’s behavior points at a real test.