Skip to main content

constant-docs

Documentation that fails the build when it drifts.

A Python command-line tool and coding-agent plugin for maintaining source documents. Each document records a hash of the source files it describes, and constant-docs verify exits 1 the moment the two disagree. The check is a hash comparison, so it needs no model client and no network. Your agent writes the documents. The tool names the ones that fell behind.

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 continuous-integration check. It exits 1 on drift, so a change that alters behaviour and leaves its document 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. When nothing was recorded it asks git what moved, 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 whatever the marks say. verify hashes everything and exits 1 on drift. Keep verify as the gate.

Codex and Claude Code can load the plugin. Its two document hooks run these commands: PostToolUse runs mark after each edit, and Stop runs settle --hook when the turn ends. The agent reads the returned plan and updates the affected documents before verification. Any harness that can run a command on write and at turn end does the same job.

Regeneration happens at the end of a turn, 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, which is gitignored, so a crashed session is picked up on the next run.

The harness guide walks through each route.

Kinds

Every document has a kind. The kind decides which sections its body must carry, which prompt its generator is handed, and whether a regeneration replaces the body or appends an entry.

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 covered code can raise replace
config-reference Every configuration key replace
cli-reference Every command and flag replace
manual-page One user task, written for the person doing it 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 shared writing register governs documents written from built-in prompts. It lives in a single file that all eight built-in prompts receive, and is checked against the canonical technical-writing skill. The smaller shared core can also be consumed by other agents through a pinned import. The writing-register guide describes the exports.

User manuals

A user manual is an ordered collection of small task pages. Each page is a normal tracked document, so a product change makes only the affected pages stale. Constant-Docs assembles the navigation without a model call.

Manuals declare input and output token ceilings. A page already above its input ceiling appears under blocked and is never handed to the supplied generation workflow. Split that page by user task, narrow its source, or change the budget deliberately.

This repository uses the feature itself. Start with the Constant-Docs user manual.

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
constant-docs completeness  # kinds of document the contents warrant

verify can only check what has been declared, so a repository can drift a long way undocumented while every check passes. coverage does one thing: a set difference. It reports the files no declared glob matches and no exclusion names. Declare the directories that should carry no document in the configuration, with a reason each. An exclusion nobody justified is one nobody decided.

Coverage answers whether a file has a document. It cannot answer whether the set of documents is complete. A document that was never written cannot drift, and a repository with a document per module and no specification passes every coverage check. completeness proposes the kinds your contents warrant, each with the fact behind it:

Warranted and not written (2):

  architecture
    because 17 modules are declared, and nothing draws how they meet (constant-docs.yaml)
  cli-reference
    because declares the console script `yourtool` (pyproject.toml)

It proposes from evidence in the repository, such as a console script or an exception hierarchy. The existence of a kind is not itself evidence. A library with no command line is not offered a CLI reference, because a document nobody needs still has to be kept true and still fails the build when it drifts. Nothing warrants a changelog: every repository could keep one, so the signal fires everywhere and says nothing.

Write the document, or decline the kind under unwarranted: with a reason, and it stops being proposed.

verify --coverage and verify --completeness fold the two into the gate, both opt-in. init proposes. It never removes: dropping a module orphans its document, and prune deletes it.

Files it will not touch

Pointing the tool at a docs/ folder the repository already has 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:

  • apply refuses to write over one. It names the file and changes no byte
  • prune refuses to delete one, and names every file it left alone
  • verify lists them under a heading of their own, and none of them fails the check

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 hash 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 that write 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. 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

constant-docs itself calls no model. It 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. 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 or exited non-zero. 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. Retiring one is deliberate: name it with --retire and the retirement is recorded in the document, with a date.

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 listed is still raised in the code. The check needs no parsing beyond a string scan, so it costs nothing, and it is the one document here the tool can 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]
created: '2026-08-19'
updated: '2026-08-19'

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.8.0
  generator_spec: https://github.com/daemon-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

A batch carrying the same event ID twice has the later one discarded, and the
report records that it happened.

## 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 hash. 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, tags and the two dates 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 hashes the bytes of the files that glob matches. Nothing in that step knows a language, which is what makes it work on yours: no model client, no network call, no syntax tree, no import graph. The cost is that any byte moves the hash. 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 a cross-cutting document takes in the modules it talks about through covers and never restates their facts.

It cannot catch a decision reworded into something weaker. The decision guard draws one line, between a decision present and a decision deleted. Degradation is left to review.

It ships one ready-made integration, the Claude Code plugin. For any other agent you wire mark and settle to whatever signals it gives you, which takes a few lines of shell.

This README is not one of the tracked documents. The tool does not track prose like this, so it is revised by hand, and nothing regenerates it.

Reading further

Licence

Apache 2.0. See LICENSE.

Release files for constant-docs 0.9.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for constant-docs 0.9.1
File Size Uploaded
constant_docs-0.9.1.tar.gz 129.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for constant-docs 0.9.1
File Interpreter ABI Platform
constant_docs-0.9.1-py3-none-any.whl Python 3 none any Details

Total release size: 282.8 kB

Release files / constant_docs-0.9.1.tar.gz

Download URL constant_docs-0.9.1.tar.gz
Size 129.4 kB
Tags Source
SHA-256 checksum
How to use checksums
47ffffabb788fd8c20ae113a0356137d13968e2cd92bbc68ab1255c14b028b39
BLAKE2b-256 checksum
How to use checksums
48571ec482fc917edba376bd28b101558324eb863f9e505922a421565dd35f80
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 Sep 19, 2026.

Transparency log

Release files / constant_docs-0.9.1-py3-none-any.whl

Download URL constant_docs-0.9.1-py3-none-any.whl
Size 153.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
090284421d5534205c6ecdc63bc17d780ea4f9dc7c1f108116710397365f38c8
BLAKE2b-256 checksum
How to use checksums
64630867fd1b6342b2da34de0800e0ae293147c8e626581f0ddfe2de60e53d4e
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 Sep 19, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.1 This release

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page