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-lint — on 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-lintjob as a required status check, andstrict: "true"once the existingcontext/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
CODEOWNERSline 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 files — README.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.