constant-docs
Documentation that writes and maintains itself.
A command-line tool and a Python library. It runs in any repository, in any language, driven by whatever coding agent you already use.
Agents ship code faster than anyone can keep the docs up to date.
Each document records a hash of the code behind it. Change that code and the document goes stale. constant-docs names what fell behind, and your agent rewrites it.
$ constant-docs verify
Stale modules: src/payments
How the loop works
Edit a file, and the document describing it is already behind. The loop closes inside the same turn:
$ constant-docs verify
All modules up to date.
$ echo 'def refund(charge_id: str) -> None: ...' >> src/payments/core.py
$ constant-docs verify
Stale modules: src/payments
$ # your agent reads the source, rewrites docs/src/payments.md, and:
$ constant-docs verify
All modules up to date.
Your coding agent writes every word, because it already holds the source and a model connection. constant-docs names the documents that moved and takes the new text back.
That one command is also the whole CI integration. It exits 1, and a change that alters behaviour while leaving its documentation behind does not merge:
- run: constant-docs verify
Install
pip install constant-docs # or: uv add constant-docs
One dependency, PyYAML. Python 3.11 or newer.
Try it
cd examples/sample-repo
constant-docs verify
All modules up to date.
Two modules, two documents, and the hash each document records still matches
the source it names. Append a line to src/payments/core.py and the same
command answers Stale modules: src/payments.
Point it at your own repository with a constant-docs.yaml at the root:
version: 1
docs_root: docs
modules:
src/payments: src/payments/**/*.py
src/ledger: src/ledger/**/*.py
constant-docs verify now names what is missing.
Every command and flag is in the CLI reference; every configuration key is in the configuration reference. Neither is restated here, because a second copy of the thing that changes most is the copy that goes stale.
Driving it from your agent
Three commands carry the whole integration, and none of them names a particular tool:
| Command | Run it | It does |
|---|---|---|
constant-docs mark <path> |
after your agent writes a file | notes which documents that path affects. A glob match and a dictionary insert |
constant-docs settle --json |
when your agent goes quiet | names what the marks turned up, and hands over each document's files, its previous text, and the conventions to write against |
constant-docs apply <module> "<body>" |
once your agent has written | stores the text and moves the hash |
A shell script drives that loop. So does a Makefile, a git hook, or a person:
$ git diff --name-only | xargs constant-docs mark
$ constant-docs settle --json
settle reports what mark recorded — and when nothing was recorded, it asks
git what moved rather than reporting an empty list, so a harness that skips the
marking step still gets real work back. Outside a git checkout it hashes every
module instead: slower, and never a clean report on no evidence. plan --json
considers every module regardless, and verify hashes everything and exits 1
on drift whatever the marks say. Keep verify as the gate.
Claude Code gets a plugin. Install it and two hooks run
those commands for you: PostToolUse runs mark, Stop runs settle --hook.
Edit a file, stop the turn, and its document is up to date. Nobody asked. Any
harness that can run a command on write and at turn end does the same job.
Regeneration happens at quiescence, never mid-edit, so the generator and the
coding loop never write the same file at once. The dirty set lives in
.constant-docs/dirty.json — gitignored — so a crashed session is picked up on
the next run.
The harness guide walks through each route.
Kinds
A document is not always a module explainer. Its kind decides which sections it must carry, which prompt its generator is handed, and whether a regeneration replaces the body or appends to it.
| Kind | For | Mode |
|---|---|---|
module |
A set of source files. The default | replace |
spec |
What a subsystem promises, and why | replace |
log |
A changelog or build log | append |
architecture |
The module map, with a diagram | replace |
errors |
Every message the tool can emit | replace |
config-reference |
Every configuration key | replace |
cli-reference |
Every command and flag | replace |
modules:
src/payments: src/payments/**/*.py # kind `module`, the default
SPEC:
glob: ["src/**/*.py", "tests/test_*.py"]
kind: spec
SECURITY:
covers: [src/payments, src/web] # follows when either glob moves
kind: spec
An append never rewrites or reorders what is already there. Pass a whole body to a log, or a single entry to a module document, and the tool refuses it. A configuration that has never heard of kinds behaves exactly as it did.
One house style governs every generated document. It lives in a single file that all seven prompts receive, so the rules cannot drift apart.
Finding what is undocumented
constant-docs init # what is here, and what covers none of it
constant-docs coverage # source no module covers, by directory
verify can only check what has been declared, so a repository could otherwise
drift to a large fraction undocumented while every check passed. coverage does one thing: a set difference. Declare the directories that
should carry no document in configuration, with a reason each. An
exclusion nobody justified is one nobody decided.
verify --coverage folds it into the gate, opt-in. init proposes and never
removes: dropping a module orphans its document, and prune deletes it.
Files it will not touch
Already have a docs/ folder? Pointing the tool at it is safe. Every
document this tool writes carries a block naming the tool, and — when you
declare a project id — the project that wrote it. A markdown file without
that block belongs to somebody else, and three commands act on the difference:
applyrefuses to write over one. It names the file and changes no byteprunerefuses to delete one, and names every file it left aloneverifylists them under a heading of their own, and they fail nothing
Take one over when you want it maintained:
constant-docs adopt docs/src/payments.md
adopt stamps the block onto the file and keeps the body word for word.
Reclaiming a document whose block an editor stripped is the same command, and
so is moving a document to a renamed module.
Adoption takes a body in whatever shape it arrives. A page somebody wrote by
hand carries none of the headings its kind declares, and adoption settles one
question: who owns the file. verify then lists that file under its
conformance issues and names the headings the kind wants, so the shortfall
becomes work your agent can pick up. The file is yours by then, so apply may
rewrite it. Every body your agent generates still carries those headings, and
apply checks before it writes a byte.
A glob that matches no file gets the same restraint. verify names the module
and exits 1, apply refuses to record a digest over nothing, and the document
stays where it is. A mistyped glob costs you a failing check and nothing else.
Documents outside the repository
Set docs_store and documents land in a directory the repository does not own
— an Obsidian vault, a shared folder, wherever your notes already live. A store
may be shared, so it requires a project id. That id keeps two repositories
writing into one vault clear of each other's documents.
project: payments-api
docs_store: ${VAULT}/Engineering/payments
docs_root goes on naming the documents, so verify and every report still
say docs/src/payments.md. The store answers the one question that wants a
real directory on this machine: where to open the file.
The variable must resolve when the configuration loads. Unset, empty, relative,
or naming a directory that is not there — each refuses the configuration and
says which. A store landing inside .ssh, Secret/, a cache or any other
directory on the deny list is refused too. The tool declines to read those
directories, so it declines to write documents into them. Plain ${NAME} is the whole syntax, because a default value would
let a machine without the vault write documents into the repository and report
success. The Stop hook blocks on the same failure, so a missing variable cannot
switch the gate off quietly.
Deletion changes as well. A repository's documents ride the branch, and git checkout brings one back. A store stands still while the checkout moves, so
prune lists what it would remove and waits for --delete. What it removes
then goes to .constant-docs-trash/<timestamp>/ inside the store.
Running it on a schedule
Hooks need a live session. Changes also arrive from a timer, a chat client, or
somebody editing files directly, and nothing fires for those. auto runs a
command you declare, then checks what it left behind:
auto:
command: "<your agent> -p 'Run the constant-docs settle loop'"
when: stale
budget: 5
The tool still calls no model. constant-docs splits your command with shlex
and runs it directly, so an agent CLI, a script and a Makefile target all work.
No credential goes in it, and the command's success is checked: auto
re-runs verify afterwards. It exits 0 when everything is clean, 1 when
something is still stale, and 2 when the command could not run. Three
outcomes, because a scheduler that confuses the last two retries the wrong
one.
Three things it checks that a hash cannot
A regeneration cannot drop a decision. A document rewritten by a model can lose the thing it existed to record. Give each recorded decision an identifier and the tool refuses a rewrite that loses one, naming it. A rewrite may reword every decision it holds. Losing one costs a refusal, and retiring one is deliberate and gets written into the document.
A cross-cutting document follows what it covers. covers: [api, cli] takes
in those modules' files by name, so it goes stale when their boundary
moves.
An error catalogue agrees with the source, both ways. Every message the code raises appears in the catalogue, and every entry still exists in the code. Neither needs parsing beyond a string scan, so it costs nothing — and it is the one document here the tool can actually guarantee. The rest are only fresh.
What a document looks like
---
type: Code Module
title: Payments
description: Reconciles inbound payment events against ledger entries.
tags: [code-module]
constant_docs:
project: payments-api
module: src/payments
kind: module
source_glob: src/payments/**/*.py
source_files:
- src/payments/core.py
- src/payments/reconcile.py
source_hash: sha256:a3f1c07e…
hash_method: sha256-over-sorted-path-and-content
hash_covers: source_files
timestamp: '2026-08-19T09:14:00Z'
generator: constant-docs/0.5.0
generator_spec: https://github.com/ashborn-systems/constant-docs/blob/main/SPEC.md
---
## Purpose
Owns the mapping between external payment events and internal ledger entries.
Callers submit a batch and receive a report. Matching strategy, retry policy,
and duplicate handling are internal.
## Correctness pillars
WHEN a batch contains a duplicate event ID, the module SHALL discard the later
event and record it in the report.
## Known failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Report totals disagree with the ledger | Batch submitted while a ledger write was in flight | Submit batches only from the settled-state queue |
The frontmatter stands alone. An agent that has never run this tool can see
what the document describes, where its source lives, and how to recompute the
digest. project answers the question asked before every overwrite and every
deletion: whose document is this. The tool keeps everything it owns under one
constant_docs key, an Open Knowledge Format producer extension. type,
title and tags stay yours, and unknown keys survive a write.
Python API
from constant_docs import adopt, append, apply, plan, prune, settle, verify
p = plan(changed_paths=["src/payments/reconcile.py"])
apply("src/payments", body="## Purpose\n…") # refused if another project owns the file
append("CHANGELOG", entry="…", title="0.3.0")
adopt("docs/src/payments.md") # take over a file the tool did not write
report = verify() # no writes, no network
prune() # deletes orphans; under a store, lists them
What it does not do
A module names a glob, and the tool digests the bytes of the files that glob matches. Nothing in that step knows a language, which is what makes yours work: no model client, no network call, no AST, no import graph. The cost is that any byte moves the hash, so fixing a typo in a comment reports the module stale and your agent rewrites a document the edit never touched.
It watches files and nothing else. A document quoting a queue depth or a production URL stays fresh for ever, because nobody edited anything. Hash what lives in files. Leave live values to something that owns them.
It cannot catch two documents contradicting each other. Content hashing sees one thing: a document falling behind its source. That is why the tool always moves a section and never copies it.
And it cannot catch a decision reworded into something weaker. The guard draws one line, between present and deleted. Degradation is left to review, and said so here so that you learn it now.
It ships one ready-made integration. The Claude Code plugin installs and runs.
For any other agent you wire mark and settle to whatever signals it gives
you, which takes a few lines of shell.
This file maintains itself. It carries the tool's own block in an HTML comment, which GitHub renders as nothing. It goes stale whenever the CLI, the kinds or the Python API move.
Reading further
- Specification — the full design and its 183 acceptance criteria
- Architecture — what the parts are and how they meet
- CLI reference and configuration reference
- Error catalogue — every message, its cause, and what to do
- examples/ — a worked repository with its documents committed
Licence
Apache 2.0 — see LICENSE.
Release files for constant-docs 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| constant_docs-0.5.0.tar.gz | 112.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| constant_docs-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 244.8 kB
Release files / constant_docs-0.5.0.tar.gz
| Download URL | constant_docs-0.5.0.tar.gz |
|---|---|
| Size | 112.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5d1f7d0b309db1c78cc14193775942004a2337a571c9ecb1336ec6d354a61939
|
|
BLAKE2b-256 checksum How to use checksums |
e94c79670ca4476c5661b4479c9c5b16a825d7acea4435b720c5d2ee53e6d2d0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 31, 2026.
Transparency logRelease files / constant_docs-0.5.0-py3-none-any.whl
| Download URL | constant_docs-0.5.0-py3-none-any.whl |
|---|---|
| Size | 132.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1a616aa3e1e7ca8cb3b510529490b1228e6f6529df9fe40ebf1e6b890712a5f4
|
|
BLAKE2b-256 checksum How to use checksums |
bcd2c422082511402c2b62726d449754ac3f526c8c3015137ab7d140c54097fc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 31, 2026.
Transparency log