constant-docs 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
constant_docs/kinds.py ADDED
@@ -0,0 +1,304 @@
1
+ """Document kinds — what shape a document is, and how a regeneration writes it.
2
+
3
+ A kind decides four things: the sections a body must carry, the OKF ``type``
4
+ the document declares, the prompt a generator is handed, and whether a
5
+ regeneration replaces the body or appends an entry to it.
6
+
7
+ Seven kinds ship. Any of them may be redeclared in configuration, and new ones
8
+ declared alongside. ``module`` is the default, so a configuration that has
9
+ never heard of kinds loads unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Literal, get_args
17
+
18
+ from constant_docs.document import FRONTMATTER_STYLES, YAML_FRONTMATTER
19
+
20
+ Mode = Literal["replace", "append"]
21
+ MODES: tuple[str, ...] = get_args(Mode)
22
+
23
+ _PROMPT_DIR = Path(__file__).parent / "prompts"
24
+ # Authoring conventions for documents this tool does not track. A README and a
25
+ # quickstart are written once and revised by judgement — what to lead with,
26
+ # what to leave out, who the reader is — so making them tracked documents would
27
+ # regenerate prose whose quality never depended on the code. The conventions
28
+ # are still worth shipping, on the same shelf as the per-kind prompts.
29
+ #
30
+ # A skill makes a document good. It does not make it fresh: nothing fires when
31
+ # the CLI moves underneath a README written six months ago. That is what the
32
+ # CLI reference is for, and why these sit beside it rather than replacing it.
33
+ _GUIDE_DIR = Path(__file__).parent / "guides"
34
+ # One home for the vocabulary rules every generated document obeys. Appended to
35
+ # the built-in prompts rather than written into each of the seven, because
36
+ # seven copies of a house style is the drift this tool exists to prevent: the
37
+ # copy nobody edits is the one that goes stale, and here that failure is silent
38
+ # because a prompt has no source to fall behind.
39
+ #
40
+ # A project naming its own prompt file gets exactly what it wrote. Carrying
41
+ # your own conventions without forking the tool has to mean your conventions,
42
+ # or the escape hatch is not one.
43
+ _HOUSE_STYLE = Path(__file__).parent / "house-style.md"
44
+ _BUILTIN_PREFIX = "builtin:"
45
+
46
+
47
+ class KindError(Exception):
48
+ """A kind is declared wrongly, or its prompt cannot be resolved."""
49
+
50
+
51
+ # The content checks a kind's documents can be subject to. Named here so a
52
+ # kind can carry one as data; `checks` implements them.
53
+ ARCHITECTURE_CHECK = "architecture"
54
+ ERRORS_CHECK = "errors"
55
+ CHECKS = (ARCHITECTURE_CHECK, ERRORS_CHECK)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Kind:
60
+ """One document shape."""
61
+
62
+ name: str
63
+ sections: list[str] # H2 headings, as written in configuration: "## Purpose"
64
+ mode: Mode
65
+ prompt: str # "builtin:<name>", or a repository-relative path
66
+ type: str # the OKF `type` value this kind's documents declare
67
+ # The content check these documents must pass, if any.
68
+ #
69
+ # A field rather than the kind's *name* compared in three places. The name
70
+ # was the earlier reading and it made the check stop following the kind: a
71
+ # project declaring `house-architecture` with `prompt: builtin:architecture`
72
+ # got the prompt and the type and silently lost the diagram guard, in both
73
+ # the writer and the reader, while `verify` went on passing.
74
+ check: str | None = None
75
+ # How the tool's block is written into this kind's documents: as YAML
76
+ # frontmatter, or as an HTML comment a Markdown host renders as nothing.
77
+ #
78
+ # A field on the kind rather than a rule inferred from where the document
79
+ # lands. Switching a file's format because it moved is the kind of thing
80
+ # that surprises somebody a year later, and the two forms are not equally
81
+ # good: YAML frontmatter is what makes the docs tree an Open Knowledge
82
+ # Format bundle, so it stays the default and the comment form is asked
83
+ # for by name.
84
+ frontmatter: str = YAML_FRONTMATTER
85
+
86
+
87
+ # The shipped kinds. Each carries defaults rather than being abstract, so
88
+ # `kind: spec` works without anyone writing a prompt first; a project that
89
+ # wants different sections redeclares the kind in configuration.
90
+ #
91
+ # Two kinds have their *content* checked rather than only hashed, and both
92
+ # checks live in `checks`. An `architecture` diagram may name only modules the
93
+ # configuration declares. An `errors` catalogue must agree with the source in
94
+ # both directions: every message raised is listed, and every entry listed is
95
+ # still raised.
96
+ BUILTIN_KINDS: dict[str, Kind] = {
97
+ "module": Kind(
98
+ name="module",
99
+ sections=["## Purpose", "## Correctness pillars", "## Known failure modes"],
100
+ mode="replace",
101
+ prompt="builtin:module",
102
+ type="Code Module",
103
+ ),
104
+ "spec": Kind(
105
+ name="spec",
106
+ sections=["## Purpose", "## Design decisions", "## Acceptance criteria"],
107
+ mode="replace",
108
+ prompt="builtin:spec",
109
+ type="Specification",
110
+ ),
111
+ "architecture": Kind(
112
+ name="architecture",
113
+ sections=["## The parts", "## How they meet", "## Boundaries"],
114
+ mode="replace",
115
+ prompt="builtin:architecture",
116
+ type="Architecture",
117
+ check=ARCHITECTURE_CHECK,
118
+ ),
119
+ "errors": Kind(
120
+ name="errors",
121
+ sections=["## Purpose", "## The catalogue", "## What is not an error"],
122
+ mode="replace",
123
+ prompt="builtin:errors",
124
+ type="Error Catalogue",
125
+ check=ERRORS_CHECK,
126
+ ),
127
+ "config-reference": Kind(
128
+ name="config-reference",
129
+ sections=["## Purpose", "## Keys", "## What is refused"],
130
+ mode="replace",
131
+ prompt="builtin:config-reference",
132
+ type="Configuration Reference",
133
+ ),
134
+ "cli-reference": Kind(
135
+ name="cli-reference",
136
+ sections=["## Purpose", "## Commands", "## Flags"],
137
+ mode="replace",
138
+ prompt="builtin:cli-reference",
139
+ type="CLI Reference",
140
+ ),
141
+ "log": Kind(
142
+ name="log",
143
+ sections=["## Entries"],
144
+ mode="append",
145
+ prompt="builtin:log",
146
+ type="Log",
147
+ ),
148
+ }
149
+
150
+ DEFAULT_KIND = "module"
151
+
152
+
153
+ def load_kinds(raw: Any) -> dict[str, Kind]:
154
+ """Merge a configuration's ``kinds`` block over the shipped defaults.
155
+
156
+ Raises :exc:`KindError` naming the offending kind. The caller turns that
157
+ into a `ConfigError`, because from the outside this is a configuration
158
+ problem and criterion 21 requires the offending key to be named.
159
+ """
160
+ kinds = dict(BUILTIN_KINDS)
161
+ if raw is None:
162
+ return kinds
163
+ if not isinstance(raw, dict):
164
+ raise KindError(
165
+ f"'kinds' must be a mapping of kind name to declaration, "
166
+ f"got {type(raw).__name__}"
167
+ )
168
+
169
+ for name, decl in raw.items():
170
+ name = str(name)
171
+ if not isinstance(decl, dict):
172
+ raise KindError(
173
+ f"Kind {name!r} must be a mapping with 'sections' and 'mode', "
174
+ f"got {type(decl).__name__}"
175
+ )
176
+
177
+ base = kinds.get(name)
178
+
179
+ sections_raw = decl.get("sections", base.sections if base else None)
180
+ if not isinstance(sections_raw, list) or not sections_raw:
181
+ raise KindError(
182
+ f"Kind {name!r} needs 'sections': a non-empty list of H2 "
183
+ f"headings, e.g. ['## Purpose', '## Entries']"
184
+ )
185
+ sections = [_normalise_heading(name, s) for s in sections_raw]
186
+
187
+ mode = str(decl.get("mode", base.mode if base else "replace"))
188
+ if mode not in MODES:
189
+ raise KindError(
190
+ f"Kind {name!r} has mode {mode!r}; it must be one of "
191
+ f"{', '.join(sorted(MODES))}"
192
+ )
193
+
194
+ prompt = str(decl.get("prompt", base.prompt if base else f"builtin:{name}"))
195
+ # Title case rather than the bare name, because this becomes the OKF
196
+ # `type` a reader sees at the top of the document.
197
+ fallback_type = base.type if base else name.replace("_", " ").title()
198
+ doc_type = str(decl.get("type", fallback_type))
199
+ if not doc_type.strip():
200
+ raise KindError(f"Kind {name!r} declares an empty 'type'")
201
+
202
+ # Inherited from the kind being redeclared, so overriding an
203
+ # `architecture` kind's sections does not quietly drop its diagram
204
+ # guard, and declarable outright so a project can name its own.
205
+ check = decl.get("check", base.check if base else None)
206
+ if check is not None:
207
+ check = str(check)
208
+ if check not in CHECKS:
209
+ raise KindError(
210
+ f"Kind {name!r} declares check {check!r}, which this "
211
+ f"version does not implement. Available: "
212
+ f"{', '.join(sorted(CHECKS))}"
213
+ )
214
+
215
+ frontmatter = str(
216
+ decl.get("frontmatter", base.frontmatter if base else YAML_FRONTMATTER)
217
+ )
218
+ if frontmatter not in FRONTMATTER_STYLES:
219
+ raise KindError(
220
+ f"Kind {name!r} declares frontmatter {frontmatter!r}, which is "
221
+ f"not a form this version writes. Available: "
222
+ f"{', '.join(FRONTMATTER_STYLES)}"
223
+ )
224
+
225
+ kinds[name] = Kind(
226
+ name=name,
227
+ sections=sections,
228
+ mode=mode, # type: ignore[arg-type]
229
+ prompt=prompt,
230
+ type=doc_type,
231
+ check=check,
232
+ frontmatter=frontmatter,
233
+ )
234
+
235
+ return kinds
236
+
237
+
238
+ def _normalise_heading(kind_name: str, raw: Any) -> str:
239
+ """Return a section as ``"## Heading"``, whichever form was written.
240
+
241
+ Configuration examples show the hashes, and a heading written with the
242
+ wrong number of them — or none — is a typo rather than a different
243
+ heading. Accepting every form costs one line and removes a class of silent
244
+ mismatch where a body is rejected for a section its author believes they
245
+ declared. Sections are always H2: the title is a frontmatter key, so a
246
+ body carries no H1.
247
+ """
248
+ text = str(raw).strip()
249
+ if not text:
250
+ raise KindError(f"Kind {kind_name!r} declares an empty section heading")
251
+ if text.startswith("#"):
252
+ text = text.lstrip("#").strip()
253
+ if not text:
254
+ raise KindError(f"Kind {kind_name!r} declares a section heading with no text")
255
+ return f"## {text}"
256
+
257
+
258
+ def guide_names() -> list[str]:
259
+ """Return the authoring guides this release ships, sorted."""
260
+ return sorted(p.stem for p in _GUIDE_DIR.glob("*.md"))
261
+
262
+
263
+ def guide_path(name: str) -> Path:
264
+ """Return the path to a shipped authoring guide, whether or not it exists."""
265
+ return _GUIDE_DIR / f"{name}.md"
266
+
267
+
268
+ def _with_house_style(prompt: str) -> str:
269
+ """Append the shared vocabulary rules to a built-in prompt.
270
+
271
+ Separated by a rule and carrying no heading of its own, because every
272
+ built-in prompt tells the generator to emit an exact set of H2 sections
273
+ and an H2 here would read as one more of them.
274
+ """
275
+ house = _HOUSE_STYLE.read_text(encoding="utf-8")
276
+ return f"{prompt.rstrip()}\n\n---\n\n{house.strip()}\n"
277
+
278
+
279
+ def resolve_prompt(kind: Kind, repo_root: Path) -> str:
280
+ """Return the generation conventions for *kind*.
281
+
282
+ ``builtin:<name>`` reads the file shipped inside the package and appends
283
+ the shared house style; anything else is a repository-relative path,
284
+ returned verbatim, so a project can carry its own conventions without
285
+ forking the tool.
286
+ """
287
+ if kind.prompt.startswith(_BUILTIN_PREFIX):
288
+ name = kind.prompt[len(_BUILTIN_PREFIX) :]
289
+ path = _PROMPT_DIR / f"{name}.md"
290
+ if not path.is_file():
291
+ raise KindError(
292
+ f"Kind {kind.name!r} names built-in prompt {kind.prompt!r}, "
293
+ f"which this version does not ship. Available: "
294
+ f"{', '.join(sorted(p.stem for p in _PROMPT_DIR.glob('*.md')))}"
295
+ )
296
+ return _with_house_style(path.read_text(encoding="utf-8"))
297
+
298
+ path = repo_root / kind.prompt
299
+ if not path.is_file():
300
+ raise KindError(
301
+ f"Kind {kind.name!r} names prompt file {kind.prompt!r}, "
302
+ f"which does not exist under {repo_root}"
303
+ )
304
+ return path.read_text(encoding="utf-8")
constant_docs/paths.py ADDED
@@ -0,0 +1,246 @@
1
+ """Module-to-document path mapping, orphan detection, and ownership.
2
+
3
+ The path mapping is pure structure. Orphan detection is not: deciding whether
4
+ a document is orphaned means opening it, because the question *"should this be
5
+ deleted"* now depends on whether this tool wrote it in the first place.
6
+
7
+ That is the deletion guard. `prune` deletes what :func:`orphans` reports, and
8
+ before 0.3.1 that was every markdown file under the docs root no live module
9
+ claimed — including files the tool had never touched.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from constant_docs.config import Config, document_path, is_tool_owned
20
+ from constant_docs.document import DocumentError, extension_block, is_tracked
21
+ from constant_docs.document import load as load_document
22
+ from constant_docs.fingerprint import compute
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OrphanReport:
27
+ """A document whose module glob no longer matches any files."""
28
+
29
+ # Repository-relative, like `ModulePlan.doc_path` and every other path this
30
+ # package hands across a module boundary. The caller joins it onto the
31
+ # repository root at the moment it needs a real file.
32
+ #
33
+ # Held absolute once, and it leaked: this is the value `verify --json` and
34
+ # `plan --json` serialise, so a report carried the machine's own directory
35
+ # layout and two runs of the same repository on different machines were not
36
+ # comparable — while `stale[].doc_path` beside it stayed relative. The rule
37
+ # is the fix, not a formatter at each of the six places that print it: a
38
+ # path that travels is relative, an absolute one is built where it is used
39
+ # and does not outlive the call.
40
+ doc_path: Path
41
+ module_key: str
42
+ # Set only when exactly one candidate matched. Several equally-good
43
+ # candidates is not a weaker answer, it is a different one: naming any of
44
+ # them would be a guess presented as a fact.
45
+ probable_move: str | None = None
46
+ probable_moves: list[str] = field(default_factory=list)
47
+
48
+
49
+ def is_ours(doc: Path) -> bool:
50
+ """Return whether *doc* is a document this tool wrote.
51
+
52
+ A file that cannot be opened or cannot be parsed is **not** ours. That is
53
+ the safe direction and it is also the honest one: a file we cannot read is
54
+ the last thing that should be deleted on our say-so.
55
+
56
+ `ValueError` is in the tuple for `UnicodeDecodeError`, which subclasses it
57
+ rather than `OSError`. A single non-UTF-8 `.md` under the docs root used
58
+ to raise straight out of here, and out of `prune` after the deletions had
59
+ already happened.
60
+ """
61
+ try:
62
+ return is_tracked(load_document(doc).frontmatter)
63
+ except (OSError, DocumentError, yaml.YAMLError, ValueError):
64
+ return False
65
+
66
+
67
+ def foreign(config: Config, repo_root: Path | None = None) -> list[Path]:
68
+ """Return every markdown file under the docs root this tool did not write.
69
+
70
+ Reported, never deleted. `prune` names each one, because silence there
71
+ reads as *"there was nothing else"* — and the thing being claimed is that
72
+ a command which just deleted files left these alone.
73
+
74
+ The tool's own furniture is excluded — `index.md` carries no block by
75
+ design — and `config.is_tool_owned` is the one place that says so, for
76
+ all of the scanners that need to know.
77
+ """
78
+ docs_root = (repo_root or Path.cwd()) / config.docs_root
79
+ if not docs_root.is_dir():
80
+ return []
81
+ return [
82
+ doc
83
+ for doc in sorted(docs_root.rglob("*.md"))
84
+ if not is_tool_owned(doc, docs_root) and not is_ours(doc)
85
+ ]
86
+
87
+
88
+ def doc_path(module_key: str, docs_root: Path, document: str | None = None) -> Path:
89
+ """Return the document path for a module.
90
+
91
+ ``doc_path("src/payments", Path("docs"))`` → ``Path("docs/src/payments.md")``
92
+
93
+ One definition, in `config`, because the loader refuses two keys that
94
+ resolve to one path and that check must agree with this mapping exactly.
95
+
96
+ Prefer :func:`doc_path_for` where a configuration is in hand. This
97
+ spelling cannot see a module's declared `document`, so passing only a key
98
+ silently writes to the derived path — which is the whole failure the
99
+ declaration exists to avoid.
100
+ """
101
+ return document_path(module_key, docs_root, document)
102
+
103
+
104
+ def doc_path_for(config: Config, module_key: str) -> Path:
105
+ """Return the document path for *module_key* under *config*.
106
+
107
+ The spelling to reach for. A module may declare where its document is
108
+ written, and a caller holding only the key and the docs root cannot know
109
+ that — so every path the tool reads or writes resolves through here, and
110
+ a module the configuration does not declare falls back to the derived
111
+ path rather than raising, because callers already handle a missing file.
112
+ """
113
+ for mod in config.modules:
114
+ if mod.key == module_key:
115
+ return document_path(module_key, config.docs_root, mod.document)
116
+ return document_path(module_key, config.docs_root)
117
+
118
+
119
+ def module_for(doc: Path, docs_root: Path) -> str | None:
120
+ """Inverse of :func:`doc_path`.
121
+
122
+ Returns the module key, or ``None`` if *doc* is not under *docs_root*.
123
+ """
124
+ try:
125
+ rel = doc.relative_to(docs_root)
126
+ except ValueError:
127
+ return None
128
+ if rel.suffix != ".md":
129
+ return None
130
+ return rel.with_suffix("").as_posix()
131
+
132
+
133
+ def orphans(config: Config, repo_root: Path | None = None) -> list[OrphanReport]:
134
+ """Return orphan reports for documents whose module is no longer active.
135
+
136
+ A module is active when its globs still match at least one file.
137
+ *repo_root* is the parent of the config file; defaults to CWD.
138
+
139
+ When two orphan documents carry the same ``source_hash``, they are
140
+ reported as a probable move (AC 15).
141
+ """
142
+ reports: list[OrphanReport] = []
143
+ root = repo_root or Path.cwd()
144
+ docs_root = root / config.docs_root
145
+ if not docs_root.is_dir():
146
+ return reports
147
+
148
+ # Every document belonging to a live module, by path rather than by key.
149
+ #
150
+ # The key-to-path mapping appends `.md` and its inverse strips it, so a
151
+ # module keyed `SPEC.md` writes `docs/SPEC.md` and reads back as `SPEC` —
152
+ # a key that is not configured, so its own fresh document was reported
153
+ # orphaned and deleted by `prune`. Stating the guard as "this path belongs
154
+ # to a live module" rather than trusting the round trip closes it for every
155
+ # key shape, including ones nobody has thought of yet.
156
+ # Joined onto the repository root, not `docs_root.parent`: `docs_root` is
157
+ # already `root / config.docs_root`, so `.parent` strips only its last
158
+ # segment and a docs root of `docs/modules` produced
159
+ # `<repo>/docs/docs/modules/…`, a path nothing can match. The guard was
160
+ # then inert for every nested docs root — silently, and in the direction
161
+ # that deletes.
162
+ live_documents = {
163
+ root / doc_path_for(config, key)
164
+ for key, files in config.module_files.items()
165
+ if files
166
+ }
167
+
168
+ # Collect basic orphan reports
169
+ for doc_file in sorted(docs_root.rglob("*.md")):
170
+ if is_tool_owned(doc_file, docs_root):
171
+ continue
172
+ if doc_file in live_documents:
173
+ continue
174
+ mod_key = module_for(doc_file, docs_root)
175
+ if mod_key is None:
176
+ continue
177
+ # A document is ours only if it carries our frontmatter block.
178
+ #
179
+ # This is the deletion guard, and it is one predicate because the bug
180
+ # was one missing question. `prune` deletes what this function reports,
181
+ # and this function used to report every `.md` under the docs root that
182
+ # no live module claimed — so a hand-written decision record sitting in
183
+ # somebody's existing `docs/` folder was, to this tool, refuse.
184
+ #
185
+ # Separate from the conformance rule. Everything under the docs root is
186
+ # still *checked* as a tracked document, and a foreign file still makes
187
+ # `verify` exit 1. Refusing to check a file and deleting it are
188
+ # different acts, and only one of them is recoverable.
189
+ if not is_ours(doc_file):
190
+ continue
191
+ if mod_key not in config.module_files or not config.module_files[mod_key]:
192
+ reports.append(
193
+ OrphanReport(
194
+ doc_path=doc_file.relative_to(root),
195
+ module_key=mod_key,
196
+ )
197
+ )
198
+
199
+ # AC 15 — a document is orphaned and an *undocumented module* carries the
200
+ # same source_hash: that pair is a probable move.
201
+ #
202
+ # Matching orphans against other orphans was the earlier reading and is
203
+ # not what the criterion says: two dead documents agreeing tells you
204
+ # nothing, because neither module exists any more. The useful signal is an
205
+ # orphaned document whose content hash matches a module that is configured
206
+ # but has no document yet — the module was renamed and its prose can follow.
207
+ #
208
+ # Note the reach of this: the digest covers file paths, so moving files to
209
+ # a new directory changes the hash and is NOT detectable here. What this
210
+ # catches is a module key or docs-root rename, where the files stay put.
211
+ #
212
+ # Keyed by digest to a LIST, not to one key. Two configured modules can
213
+ # legitimately cover the same files — a module document and the
214
+ # specification that governs it — and a dict would silently keep whichever
215
+ # was iterated last, returning it as though it were the only answer.
216
+ undocumented: dict[str, list[str]] = {}
217
+ for mod_key, files in sorted(config.module_files.items()):
218
+ if not files:
219
+ continue
220
+ if (root / doc_path_for(config, mod_key)).exists():
221
+ continue
222
+ try:
223
+ undocumented.setdefault(compute(files, root=root), []).append(mod_key)
224
+ except OSError:
225
+ continue
226
+
227
+ final_reports: list[OrphanReport] = []
228
+ for orphan in reports:
229
+ candidates: list[str] = []
230
+ try:
231
+ doc = load_document(root / orphan.doc_path)
232
+ stored = str(extension_block(doc.frontmatter).get("source_hash", ""))
233
+ if stored:
234
+ candidates = sorted(undocumented.get(stored, []))
235
+ except (OSError, DocumentError):
236
+ pass
237
+ final_reports.append(
238
+ OrphanReport(
239
+ doc_path=orphan.doc_path,
240
+ module_key=orphan.module_key,
241
+ probable_move=candidates[0] if len(candidates) == 1 else None,
242
+ probable_moves=candidates,
243
+ )
244
+ )
245
+
246
+ return final_reports
@@ -0,0 +1,61 @@
1
+ Read the source files this document covers, then write its body.
2
+
3
+ An architecture document answers *"what are the parts and how do they meet"*.
4
+ It is not a specification: a specification says what is promised, this says what
5
+ exists. Someone should be able to read it in five minutes and know where to go
6
+ next, without reading the specification at all.
7
+
8
+ Output the body only: three H2 sections, in this order, nothing before or
9
+ after, no code fences around the whole thing. Do not write an H1 — the
10
+ document's title is a frontmatter key, not a heading.
11
+
12
+ ## The parts
13
+
14
+ A table of module, responsibility, and public interface — one row per module,
15
+ the responsibility in one line, the interface as the names a caller actually
16
+ uses. Then a Mermaid diagram of how they depend on one another.
17
+
18
+ Write the diagram inside a fenced block tagged `mermaid`, as a `graph` or
19
+ `flowchart` with a direction:
20
+
21
+ ```mermaid
22
+ graph TD
23
+ cli["cli"] --> api["api"]
24
+ api --> config["config"]
25
+ harness(A coding agent) --> cli
26
+ ```
27
+
28
+ **A quoted node label names a module and is checked against the
29
+ configuration.** Use the module's configured key, or its last path segment
30
+ when that is unambiguous. Anything that is not a module — a harness, a
31
+ filesystem, a person — takes an unquoted label, so an honest picture can show
32
+ things the configuration has never heard of.
33
+
34
+ Naming a module that does not exist is refused, which is the one guard
35
+ available here and the way these documents actually rot: a module is renamed
36
+ and the picture still shows the old name.
37
+
38
+ ## How they meet
39
+
40
+ The paths through the system, in prose. Which module calls which, what crosses
41
+ each boundary, and where a rule is enforced exactly once. Name anything that
42
+ must have a single implementation and say what breaks if it gets a second.
43
+
44
+ ## Boundaries
45
+
46
+ What each part refuses to know. Layering that is deliberate, direction of
47
+ dependency, and what is hidden behind an interface on purpose. A reader
48
+ deciding where to add something needs this more than anything else in the
49
+ document.
50
+
51
+ Rules:
52
+
53
+ - The diagram is your reading of the code, not something derived from it. This
54
+ tool parses no source — no AST, no import graph — so the picture is prose in
55
+ another form. Do not imply otherwise, and do not draw an edge you cannot
56
+ point to in the files given.
57
+ - Never restate a signature or a parameter list. Name the interface; the
58
+ reader has the source.
59
+ - When a previous body is supplied, edit it. The module map changes only when
60
+ a boundary moves, so most regenerations should change very little.
61
+ - Plain British English. No preamble, no summary of what you did.
@@ -0,0 +1,40 @@
1
+ Read the source files this document covers, then write its body.
2
+
3
+ A CLI reference answers *"what can I run, and what will it print"*. It is **the
4
+ single home for commands**: the README and the skill point here rather than
5
+ restating them, so this is the only place a command example is allowed to go
6
+ stale.
7
+
8
+ Output the body only: three H2 sections, in this order, nothing before or
9
+ after, no code fences around the whole thing. Do not write an H1 — the
10
+ document's title is a frontmatter key, not a heading.
11
+
12
+ ## Purpose
13
+
14
+ What the command is called, how it is invoked, and how it finds its
15
+ configuration. Two to four sentences.
16
+
17
+ ## Commands
18
+
19
+ Every subcommand: what it does, its arguments in order, and what it writes to
20
+ standard output. One H3 per command, with a fenced example that would actually
21
+ run. Say which commands write to the repository and which do not, because that
22
+ is the first thing a reader wants and the last thing a usage string tells them.
23
+
24
+ ## Flags
25
+
26
+ Every flag, which commands accept it, and what it changes. Include the ones
27
+ that only matter to a harness — machine-readable output, hook forms — and say
28
+ so, because a person reading top to bottom will otherwise try them.
29
+
30
+ Rules:
31
+
32
+ - Work from the argument parsing, not from the help text. A flag the parser
33
+ accepts and the help omits is exactly the drift this document exists to
34
+ catch.
35
+ - Every example must be runnable as written. No placeholders that are not
36
+ marked as such.
37
+ - Exit codes belong to the specification; link to it rather than restating the
38
+ table.
39
+ - When a previous body is supplied, edit it.
40
+ - Plain British English. No preamble, no summary of what you did.
@@ -0,0 +1,38 @@
1
+ Read the source files this document covers, then write its body.
2
+
3
+ A configuration reference answers *"what can I put in this file, and what will
4
+ it do"*. It is read with the file open, so completeness matters more than prose.
5
+
6
+ Output the body only: three H2 sections, in this order, nothing before or
7
+ after, no code fences around the whole thing. Do not write an H1 — the
8
+ document's title is a frontmatter key, not a heading.
9
+
10
+ ## Purpose
11
+
12
+ What the file is, where it lives, and what reads it. Two to four sentences.
13
+ Then a short complete example that would actually load.
14
+
15
+ ## Keys
16
+
17
+ Every key the loader accepts: name, type, default, and what it does. A table
18
+ per level of nesting, or a subheading per top-level key when a key needs more
19
+ than a line. Give the default exactly as the code has it, and say when a key is
20
+ required rather than leaving the default blank.
21
+
22
+ This is the section that rots: keys get added in code and documented never.
23
+ Work from the loader, key by key, and do not describe one you cannot find
24
+ there.
25
+
26
+ ## What is refused
27
+
28
+ The shapes the loader rejects, and why each is refused rather than ignored. A
29
+ mistyped key that is silently accepted is a setting its author believes is in
30
+ force, so the refusals are a feature and belong in the reference.
31
+
32
+ Rules:
33
+
34
+ - Never document a key the source does not read.
35
+ - Give types as a reader would write them — "a string, or a list of strings" —
36
+ not as Python annotations.
37
+ - When a previous body is supplied, edit it. A new key is a new row.
38
+ - Plain British English. No preamble, no summary of what you did.