Skip to content

Linting (CI and local)

Nothing in Keep the Why is enforced the way a compiler enforces correctness — that's stated plainly in "What this skill is not." Part of that gap is mechanically closable, though: whether every entry carries its required fields, whether the values are from the documented sets, whether index.md is complete and sorted, whether .keep-the-why is internally consistent. That part has a linter:

keep-the-why-linton PyPI as a package, on the GitHub Marketplace as an action, developed in this repository under lint/. Python 3.10+, no dependencies beyond the standard library.

What it deliberately does not check: content. Whether recorded rationale is true, complete, or honest isn't mechanically checkable, and the linter doesn't pretend otherwise — Evidence classification stays a judgment call; the linter only guarantees the field is there and holds a legal value.

Schema-version-aware

The linter reads your project's context-schema from .keep-the-why (or the legacy AGENTS.md block, for projects not yet migrated) and enforces only what that skill version defines — the same "next time touched" philosophy as migrations. An unmigrated project doesn't fail on structure its schema version never had. Only the location named by your config's context: field gets linted, plus the config file itself.

Enforced from context-schema What
0.3.0 Status/Evidence mandatory per entry, Verification values
0.7.0 Type field (missing → warning, per the skill's "next time touched" rule)
0.8.0 undefined — <reason> Type value, exclusive
0.9.0 Multiple Type lines per entry
0.10.0 Dedicated .keep-the-why (id required), sorted index.md, guard files
0.13.0 Fifth Status value pending-confirmation (an error below this schema); pending-confirmation-check as a personal-defaults field; index.md letter skeleton (## 0## 9, ## A## Z, every topic under its character)

A gate can be prepared ahead of the skill release that ships it — 0.13.0 was, as the release checklist publishes the linter before the skill tag. The linter enforces such a gate only once a project's context-schema reaches that version, which no real project's does before the release is out; until then a project writing the new value on an older schema gets an error naming the version it needs, rather than the value silently passing before it is part of any shipped schema.

Version scheme

The linter is versioned as <schema>.<revision> — e.g. 0.10.1.0. The first three segments are the newest skill schema this release knows every structural gate of; the fourth is the linter's own revision, bumped for linter-only changes (0.10.1.1). Every skill release is preceded by a linter release that knows the new version, even when nothing structural changed — a project that updates the skill and advances its context-schema lints against the new version in its next CI job, and the linter warns (W003) when a project's schema is newer than the newest one it knows. Publishing the linter first means that warning never fires for a project that is merely current.

PEP 440, not strict SemVer — PyPI rejects the build-metadata spelling SemVer would use for this. The skill itself keeps SemVer tags (v0.10.1); the linter's PyPI releases are tagged lint-v<version> in this repository, and the moving lint-latest tag follows the newest one — both created by the publish workflow itself, never by hand. The GitHub Action deliberately rides lint-latest, not the skill's latest: that tag only advances with a skill release, which doesn't carry action.yml changes until the next one.

Setup snippets

The project init wizard offers to write these for you (GitHub Actions or GitLab CI, detected from the repository; the pre-commit hook only if the project already uses pre-commit) — see CI linting setup. By hand, they're the same files:

GitHub Actions.github/workflows/ktw-lint.yml:

name: ktw-lint

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ktw-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oliver-zehentleitner/keep-the-why@lint-latest   # rolling; @lint-v<version> pins action and linter together
        with:
          path: "."
          strict: "false"    # "true" turns warnings (e.g. missing Type on old entries) into failures
          # version: "latest"     # only to mix: a pinned ref with a rolling linter, or vice versa — https://keepthewhy.com/linting/#versions-and-pinning

GitLab CI — job for .gitlab-ci.yml:

ktw-lint:
  image: python:3.12
  script:
    - pip install keep-the-why-lint
    - ktw-lint .

pre-commit — hook for .pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: ktw-lint
        name: keep-the-why-lint
        entry: ktw-lint .
        language: python
        additional_dependencies: ["keep-the-why-lint"]
        pass_filenames: false

Any other CI, or locally:

pip install keep-the-why-lint
ktw-lint .            # exit 0 clean, 1 findings, 2 usage error
ktw-lint . --strict   # warnings fail too
ktw-lint . --setup    # locally only: also ~/.keep-the-why/<id>.md and ~/.keep-the-why/config

Inside GitHub Actions, findings show up as file/line annotations on the PR.

Versions and pinning

The uses: ref decides both the action and the linter it installs, the way pinning an action usually works:

uses: ref Action Linter installed
@lint-latest (the documented default) moves with every linter release the newest — no edit needed after a skill update
@lint-v<version> that release that release's linter
@<commit-sha> that commit the linter that commit belongs to

Latest is the default on purpose: a skill release that adds a structural gate is followed by a linter release that knows it, and a workflow on @lint-latest picks that up on its own. The gating protects the other direction (an older project is never held to a newer schema), so rolling forward is the safe default. Pin when you have a reason — a linter release that misbehaves on your context/, a policy that wants every tool version fixed — and pin by ref, like any other action:

      - uses: oliver-zehentleitner/keep-the-why@lint-v0.12.0.0   # action and linter pinned together

The version: input is for the odd case of mixing — a pinned action with a different linter, or version: "latest" on a pinned ref to keep the linter rolling anyway. Anywhere else (GitLab CI, pre-commit, a plain shell), pip install keep-the-why-lint==<version> is the pin.

Checking the setup locally

A developer's setup for a project is three files, and CI only ever sees one of them: .keep-the-why is committed, while the personal file ~/.keep-the-why/<id>.md and the machine-wide ~/.keep-the-why/config live in the developer's home. --setup adds those two to the run:

ktw-lint . --setup

It is the local counterpart to the CI run, for the agent or a person to verify a setup after a settings change — a wizard answer written, a personal preference edited by hand, a global policy line appended to an image. Both home files get the same treatment as the project config: block present and closed (E013, E011, E012), no unknown or duplicated fields (E005, E004; migration-prompt may repeat, one line per version), values from the documented sets (E003), timer and source lines in their documented shape (W002), and the hidden-content checks (E301, E302, W301). A missing personal file is a warning (W004) — the personal wizard has not run on this machine for this project; a missing global config is nothing, that is the documented default. The personal file is located by the project's id, so an id that fails E010 also means the personal file is not looked for.

Opt-in on purpose. The default run never leaves the checkout — that is what makes it safe on pull requests from strangers — and a CI runner has no home files worth reading. The GitHub Action never passes the flag.

The skill runs both forms itself when a developer's personal local-lint setting says so — after every write to context/, and with --setup after a settings change — installing or updating the linter first, since its version must be at least the skill's. That is the personal wizard's fifth question; the rule is "Local linting" in setup.

Hardening for shared repositories

The linter checks structure. In a repository with many contributors, a few conventional GitHub settings turn it from a hint into a gate, and cover what it deliberately doesn't check:

  • Require the check. Branch protection (or a ruleset) on the default branch with the ktw-lint job as a required status check, and strict: "true" once the existing context/ is clean — warnings are "fix next time you touch it" material for a solo project, and a merge blocker for a team.
  • Own the rationale. A CODEOWNERS line for the context directory and the config file, so a change to a confirmed decision is reviewed by someone who can confirm it:
/context/        @your-org/architecture
/.keep-the-why   @your-org/architecture

The linter accepts any well-formed Evidence: confirmed; whether the claim is true is exactly the review this line routes. - Pin by ref, update by PR. @lint-v<version> or @<commit-sha> on the uses: line pins action and linter together (see Versions and pinning); Dependabot's github-actions ecosystem then proposes the bump as a pull request, which is where a new linter release should meet a shared repository. Rolling @lint-latest is right for a project that wants every new gate the day it ships and has nobody to review workflow bumps. - Pair with a secret scanner. Hidden-content checks catch encoded blobs and invisible characters, not credentials in plain sight — gitleaks or GitHub's own secret scanning belongs next to it, since context/ is prose that people paste into. - Keep the runtime footprint the reason it is small. The linter is stdlib-only and installs from PyPI; nothing else runs in the job. If a policy requires hashes, pip install keep-the-why-lint==<version> with --require-hashes and a constraints file works like for any package.

None of this is specific to Keep the Why; it is the same set of settings any team applies to a directory whose content is a decision record.

What it checks

.keep-the-why / legacy config block — block present, closed by its end marker, and the only one of its kind in the file (a second start marker is an error; only the first block is read); required fields (context, init, context-schema, capture-confirmation, source-reference, plus id for dedicated files since 0.10.0); values from the documented sets; filtered source-reference carries its criteria; no field recorded twice (conflicting duplicates are exactly the state the skill refuses to guess about); no unknown fields; context-schema is plain semver; id is a safe file name — letters, digits, ., _, -, nothing that could make ~/.keep-the-why/<id>.md resolve outside that directory; pinned-version/pinned-path only as a pair, with the path existing; the configured context location exists; personal-defaults blocks carry no last: timestamps. Both configured paths are confined to the repository: an absolute path, a .. escape, a control character, or a symlink that leaves the tree is an error and is not read — a CI job runs this on pull requests from strangers, and the config file is data, not a place to point the linter at the runner's filesystem.

~/.keep-the-why/<id>.md and ~/.keep-the-why/config — only with --setup, see above.

Entries (level-2 headings in topic files; fenced code blocks are skipped, so example entries in documentation never get linted as real ones) — Status and Evidence present, single, and valid; Type values valid, undefined carries a reason and combines with nothing; no duplicate Type values; Verification starts with a valid value, and contradicted must say what contradicts it; a heading with no schema fields at all is a warning, not an error — it may be a legitimate prose section.

index.md — exists; every link resolves; every topic file is listed; sorted alphabetically by filename (an error since 0.10.0 — the convention's merge-conflict benefit only exists when the whole list is sorted); since 0.13.0, the fixed ## 0## 9, ## A## Z heading skeleton is present and in order and every topic sits under its letter.

Guard filesREADME.md, AGENTS.md, CLAUDE.md inside the context directory (warnings; an equivalent doing the same job is fine).

Hidden content — invisible/directional Unicode (zero-width characters, bidi overrides) is an error; a file that is not valid UTF-8 is an error; base64-looking blobs are a warning. This is the one mechanically checkable slice of the trust model: if it needs decoding to be read, it doesn't belong in an entry meant to be read. This is not a secret scanner — pair it with one (e.g. gitleaks) if you need that.

Finding codes

Code Severity Meaning
E001 error no config block found
E002 error required config field missing
E003 error invalid config value
E004 error config field recorded more than once
E005 error unknown config field
E006 error pinned-version/pinned-path pair violation, or pinned path missing
E007 error configured context location doesn't exist
E008 error last: timestamp inside personal-defaults
E009 error configured context / pinned-path, the config file itself, or a symlink inside the context directory, points outside the repository (or contains a control character)
E010 error id is not a safe file name (path separator, .., space, control character)
E011 error config, personal-defaults, personal or global block never closed
E012 error second start marker for the same block
E013 error a file under ~/.keep-the-why/ exists but carries no personal / global block (--setup)
E101/E102 error entry missing Status / Evidence
E103/E104 error invalid Status / Evidence value
E105 error invalid Type value
E106 error invalid Verification value
E107 error Type: undefined without a reason
E108 error undefined combined with another Type value
E109 error duplicate Type value
E110 error multiple Type lines below schema 0.9.0
E111 error Verification: contradicted without explanation
E112 error more than one Status/Evidence line
E113 error Status: pending-confirmation below context-schema 0.13.0
E201–E204 error index.md missing / broken link / unlisted topic file / not sorted
E205/E206 error index.md heading skeleton missing or out of order / topic listed under the wrong letter (since 0.13.0)
E301 error invisible or directional Unicode character
E302 error file is not valid UTF-8
W001 warning context-schema missing (assumed 0.2.0)
W002 warning unrecognized check-interval shape in personal-defaults; with --setup, also a timer or source line in the personal file
W003 warning project context-schema newer than the newest schema this linter knows
W004 warning no personal file for this project on this machine, or none locatable because the id is unusable (--setup)
W101 warning entry has no Type field
W102 warning level-2 heading without any schema fields
W103 warning Type placed after Status
W104 warning Type present but schema predates 0.7.0
W105 warning empty Revisit when condition
W201 warning guard file missing
W301 warning base64-looking blob

This repository runs the linter on its own context/ in CI, from source and in --strict mode.