Metadata-Version: 2.4
Name: constant-docs
Version: 0.8.0
Summary: Self-maintaining documentation for agentic codebases
Keywords: documentation,docs,staleness,drift,ci,codegen,agents,llm
Author: Ashborn Systems
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Documentation
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Dist: pyyaml>=6.0.3
Requires-Python: >=3.11
Project-URL: Homepage, https://github.com/ashborn-systems/constant-docs
Project-URL: Repository, https://github.com/ashborn-systems/constant-docs
Project-URL: Documentation, https://github.com/ashborn-systems/constant-docs/blob/main/README.md
Project-URL: Changelog, https://github.com/ashborn-systems/constant-docs/blob/main/docs/CHANGELOG.md
Project-URL: Issues, https://github.com/ashborn-systems/constant-docs/issues
Project-URL: Specification, https://github.com/ashborn-systems/constant-docs/blob/main/docs/SPEC.md
Description-Content-Type: text/markdown

<!-- constant-docs
type: Readme
title: README
description: Self-maintaining documentation for agentic codebases.
tags:
- readme
created: '2026-08-26'
updated: '2026-08-26'

constant_docs:
  module: README
  kind: readme
  source_glob:
  - src/constant_docs/cli.py
  - src/constant_docs/kinds.py
  - src/constant_docs/api.py
  - hooks/hooks.json
  - pyproject.toml
  source_files:
  - hooks/hooks.json
  - pyproject.toml
  - src/constant_docs/api.py
  - src/constant_docs/cli.py
  - src/constant_docs/kinds.py
  source_hash: sha256:831cdb38a60c83171b114148aff40e507c1feb9c94600e9404de748e293f9b21
  hash_method: sha256-over-sorted-path-and-content
  hash_covers: source_files
  timestamp: '2026-09-05T16:46:40Z'
  generator: constant-docs/0.8.0
  generator_spec: https://github.com/ashborn-systems/constant-docs/blob/main/SPEC.md
-->


# constant-docs

Documentation that fails the build when it drifts.

A Python command-line tool for codebases written with a coding agent. 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:

```console
$ 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:

```yaml
- run: constant-docs verify
```

## Install

```bash
pip install constant-docs        # or: uv add constant-docs
```

One dependency, PyYAML. Python 3.11 or newer.

## Try it

```bash
cd examples/sample-repo
constant-docs verify
```

```console
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:

```yaml
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](docs/CLI.md). Every
configuration key is in the [configuration reference](docs/CONFIGURATION.md).
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:

```console
$ 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.

Claude Code gets a plugin. Install it and two hooks run those commands for you:
`PostToolUse` runs `mark` after each edit, and `Stop` runs `settle --hook` when
the turn ends. Edit a file, stop the turn, and its document is up to date. 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](guides/harness-integration.md) 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 |

```yaml
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 document written from a built-in prompt. It lives
in a single file that all seven built-in prompts receive, so the rules cannot
drift apart.

## Finding what is undocumented

```bash
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:

```bash
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.

```yaml
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:

```yaml
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

```markdown
---
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/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

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

```python
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

- [Specification](docs/SPEC.md): the full design and its 183 acceptance
  criteria
- [Architecture](docs/ARCHITECTURE.md): what the parts are and how they meet
- [CLI reference](docs/CLI.md) and [configuration
  reference](docs/CONFIGURATION.md)
- [Error catalogue](docs/ERRORS.md): every message, its cause, and what to do
- [examples/](examples/): a worked repository with its documents committed

## Licence

Apache 2.0. See [LICENSE](LICENSE).
