stack: Typ project und Collection kb/gtd/ (#123)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- README.md
- VERSION
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/entities/COLLECTION.md
- kb/gtd/COLLECTION.md
- kb/gtd/INDEX.md
- kb/index.md
- tools/CONTRACT.md
- tools/chemenu/kb_collections.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_kb_collections.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_types_cmd.py
- types/project.md
- types/project.schema.yaml
- types/type-spec.md
This commit is contained in:
torben committed 2026-09-19 21:13:24 +02:00
1 parent 3c9d669729
commit ee24b6e5b8
20 files changed
+507 -38

No files matched your search

+37
View File
@@ -59,6 +59,43 @@ concern - readable here, never shipped as something to parse.
--- ---
## 7.0.0-beta.1 - 2026-09-19 - Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
**Author:** Torben Nehmer
**Breaking Change:** docs verify now requires an adopted `project` type-spec (schema requiring `state:`) and its `kb/gtd/` collection - an instance must adopt types/project.md(.schema.yaml) and kb/gtd/COLLECTION.md from their .template before docs verify passes again
**Migration:** none required - no kb/ page content changes - the fix is the ordinary .template adoption every root:kb type already requires, not a data migration
<!-- wikitool:bumps -->
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
<!-- /wikitool:bumps -->
### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
Gitea #119 (Paket #123): ein neuer Seitentyp `project` fuer das Vorhaben - Ziel, Beteiligte,
dauerhafter Status, offene Schleifen - abgegrenzt gegen das Artefakt (`entity`/`codebase`, seit
6.2.0). `types/project.md`/`.schema.yaml` und die neue Collection `kb/gtd/` (Bereiche `haus/`,
`finanzen/`, `technik/` ueber `responsibility:`) folgen exakt dem Muster, das `entity`/`concept`/
`source`/`comparison` schon vorgeben - kein Code noetig fuer `wikitool new project`, `types list`
oder den Template-Versand, alles daran ist bereits generisch.
Neu ist nur eine Zeile Code: `project` tritt neben `source` in
`kb_collections.STACK_REQUIRED_TYPES`, nach demselben "fordern statt besitzen"-Idiom (D16) - ein
Type-Spec `name: project`, dessen Schema `state:` fuehrt, muss existieren, weil der
Wochenrueckblick (#125) sonst nichts hat, wogegen er ein Tracker-Projekt abgleichen kann. Das
macht `docs verify` zum Grenzuebertritt (siehe **Breaking Change** oben): eine Instanz, die die
neue `tools/`-Fassung uebernimmt, ohne `types/project.md.template` und
`kb/gtd/COLLECTION.md.template` zu adoptieren, faellt fortan durch, wo sie vorher bestand. Dabei
aufgefallen und mitkorrigiert: `kb_collections.declaration_issues()`s Meldung fuer eine fehlende
Pflicht-Collection nannte immer `source`, unabhaengig davon, welcher Typ tatsaechlich fehlte -
jetzt benennt sie den Typ, den `stack_required_collection_owners()` tatsaechlich dafuer
verantwortlich macht. `kb/entities/COLLECTION.md` traegt jetzt einen `gtd:`-Block (vorerst nur
`see-also`), ohne den keine Kante von einer Entity auf ein Vorhaben autorisierbar waere - das ist
der Block, auf den #118 wartet.
---
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt ## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
**Author:** Torben Nehmer **Author:** Torben Nehmer
+8 -2
View File
@@ -95,6 +95,7 @@ chemenu/
│ ├── concept.md # Concept type config + template (+ .guidance.md) │ ├── concept.md # Concept type config + template (+ .guidance.md)
│ ├── source.md # Source type config + template (+ .guidance.md) │ ├── source.md # Source type config + template (+ .guidance.md)
│ ├── comparison.md # Comparison type config + template (+ .guidance.md) │ ├── comparison.md # Comparison type config + template (+ .guidance.md)
│ ├── project.md # Project (Vorhaben) type config + template, no guidance file
│ ├── instruction.md # Instruction type - lives outside kb/ via `root: repo` │ ├── instruction.md # Instruction type - lives outside kb/ via `root: repo`
│ └── lint-report.md # Contract-only: describes reports/, owns no directory │ └── lint-report.md # Contract-only: describes reports/, owns no directory
├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection ├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection
@@ -103,7 +104,7 @@ chemenu/
│ ├── log.md # Generated chronological audit log │ ├── log.md # Generated chronological audit log
│ ├── provenance.md # Generated raw-file reverse index │ ├── provenance.md # Generated raw-file reverse index
│ ├── entities/ # COLLECTION.md + INDEX.md + areas below │ ├── entities/ # COLLECTION.md + INDEX.md + areas below
│ │ ├── projects/ │ │ ├── codebases/
│ │ ├── systems/ │ │ ├── systems/
│ │ ├── tools/ # own INDEX.md once past 50 pages │ │ ├── tools/ # own INDEX.md once past 50 pages
│ │ ├── technologies/ │ │ ├── technologies/
@@ -123,7 +124,11 @@ chemenu/
│ │ ├── notes/ │ │ ├── notes/
│ │ ├── trackers/ │ │ ├── trackers/
│ │ └── unclassified/ │ │ └── unclassified/
│ └── comparisons/ # COLLECTION.md - comparison pages, no subtype axis │ ├── comparisons/ # COLLECTION.md - comparison pages, no subtype axis
│ └── gtd/ # COLLECTION.md + INDEX.md + areas below
│ ├── haus/
│ ├── finanzen/
│ └── technik/
├── work/ # WORKSHOP: one directory per multi-session run, tracked ├── work/ # WORKSHOP: one directory per multi-session run, tracked
│ └── CONTRACT.md # Run keys, required files, how a run closes │ └── CONTRACT.md # Run keys, required files, how a run closes
├── reports/ # DERIVED: lint reports, traces, eval scores. Gitignored ├── reports/ # DERIVED: lint reports, traces, eval scores. Gitignored
@@ -467,6 +472,7 @@ The LLM will create and maintain:
- Entity pages in `kb/entities/` - Entity pages in `kb/entities/`
- Concept pages in `kb/concepts/` - Concept pages in `kb/concepts/`
- Comparison pages in `kb/comparisons/` - Comparison pages in `kb/comparisons/`
- Project (Vorhaben) pages in `kb/gtd/`
- Lint reports, session traces and eval scores in `reports/` (gitignored) - Lint reports, session traces and eval scores in `reports/` (gitignored)
## Changelog ## Changelog
+1 -1
View File
@@ -1 +1 @@
6.2.0 7.0.0-beta.1
+3 -2
View File
@@ -80,12 +80,13 @@ resolves against it by name.
| Collection | Holds | Contract | | Collection | Holds | Contract |
|------------|-------|----------| |------------|-------|----------|
| `kb/entities/` | Concrete things: projects, deployed systems, tools, technologies, people | [entities/COLLECTION.md](entities/COLLECTION.md) | | `kb/entities/` | Concrete things: codebases, deployed systems, tools, technologies, people | [entities/COLLECTION.md](entities/COLLECTION.md) |
| `kb/concepts/` | Architectures, patterns, protocols, workflows, decisions, recurring problems | [concepts/COLLECTION.md](concepts/COLLECTION.md) | | `kb/concepts/` | Architectures, patterns, protocols, workflows, decisions, recurring problems | [concepts/COLLECTION.md](concepts/COLLECTION.md) |
| `kb/sources/` | One summary page per ingested source, carrying its `raw_files:` provenance | [sources/COLLECTION.md](sources/COLLECTION.md) | | `kb/sources/` | One summary page per ingested source, carrying its `raw_files:` provenance | [sources/COLLECTION.md](sources/COLLECTION.md) |
| `kb/comparisons/` | Structured comparisons of two or more existing pages | [comparisons/COLLECTION.md](comparisons/COLLECTION.md) | | `kb/comparisons/` | Structured comparisons of two or more existing pages | [comparisons/COLLECTION.md](comparisons/COLLECTION.md) |
| `kb/gtd/` | One page per committed initiative (a GTD project): goal, participants, durable status, open loops | [gtd/COLLECTION.md](gtd/COLLECTION.md) |
The four rows above are this instance's collections, not a fixed set. **Adding one:** The five rows above are this instance's collections, not a fixed set. **Adding one:**
`mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md` with the two fields above. Collections `mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md` with the two fields above. Collections
are discovered by contract presence, so no code change is needed. A collection only becomes are discovered by contract presence, so no code change is needed. A collection only becomes
*writable* once some type-spec declares a matching `base_dir:`. Renaming or dropping one is the *writable* once some type-spec declares a matching `base_dir:`. Renaming or dropping one is the
+3 -2
View File
@@ -42,8 +42,9 @@ those regions and nothing else. Nothing matches on this text.
Pages are written in **German** - the `language:` in this file's own frontmatter, and the one Pages are written in **German** - the `language:` in this file's own frontmatter, and the one
place that value is written down. This binds `kb/`, and inside the page type-specs place that value is written down. This binds `kb/`, and inside the page type-specs
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`) exactly the (`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`,
parts that become page text: each one's `## Template` block, and the `layout:` titles that head a `types/project.md`) exactly the parts that become page text: each one's `## Template` block, and
the `layout:` titles that head a
catalog section. Their authoring guidance around those is instruction to an agent, so it follows catalog section. Their authoring guidance around those is instruction to an agent, so it follows
the control plane and stays English - the same prose/identifier cut the control plane and stays English - the same prose/identifier cut
[kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a page, applied one level up. [kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a page, applied one level up.
+1
View File
@@ -5,6 +5,7 @@ outbound:
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also] concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also]
sources: [evidenced-by, defined-in, see-also] sources: [evidenced-by, defined-in, see-also]
comparisons: [compares-with, see-also] comparisons: [compares-with, see-also]
gtd: [see-also]
required_by_stack: false required_by_stack: false
--- ---
+78
View File
@@ -0,0 +1,78 @@
---
profile: none
outbound:
entities: [see-also]
concepts: [see-also]
sources: [see-also]
gtd: [see-also]
required_by_stack: true
---
# kb/gtd/ - Collection Contract
One page per committed initiative (a project in the GTD sense): the goal, the participants, the
durable status, the open loops. This is the half of Muster 4 that `kb/` owns - the other half,
the moment-to-moment task list, lives in the task tracker and is joined to a page here only by
name (`AGENTS.md` invariant 8, § "Two truths about status are forbidden").
**Quality goal:** a page here should still make sense once the initiative is over. A reader
should come away knowing what was attempted, who was in it, what was decided, and what was
learned - not a snapshot of what was still open at some point in time.
Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics,
provenance, citation, the confidence machinery - and
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
tone, relationship labels, the hedging rule. Neither is restated here.
**This collection is `required_by_stack`.** A type-spec declaring `name: project` whose schema
requires `state:` must exist (`types/type-spec.md` § "What the stack still requires of the type
layer"), and `kb/gtd/` is whichever collection that type writes into - derived, not hardcoded, so
renaming it stays consistent instead of tripping a stale name.
## Types offered
`project` (`tools/wikitool types describe project`). The `responsibility:` field selects the
area:
| Area | Holds |
|------|-------|
| `haus/` | Household initiatives |
| `finanzen/` | Financial initiatives |
| `technik/` | Technical initiatives outside any tracked codebase's own scope |
These are areas, not collections: they inherit this contract and carry no `COLLECTION.md` of
their own. The initial three values are this instance's own starting vocabulary
(`types/project.md` § Frontmatter) - not a stack requirement, and free to extend.
## Two rules unique to this collection
- **`## Status` is durable, never a momentary state (D7).** The page never summarizes the task
list. "Pilotbetrieb seit 2026-03, zwei Abteilungen angebunden" is a status; "warte auf
Freigabe" is a tracker state and does not belong here. The join between a page and its tracker
project happens at read time, over the normalized title, and is never stored.
- **`## Beteiligte` carries mentions, not links (D28).** One to two lines per person, in prose,
with no `[[wikilink]]` and no page of their own. This is a deliberate, named exception to
`kb/CONTRACT.md` § "Every page should" - a project page with unlinked people in its
`## Beteiligte` section is conforming, not incomplete. A person earns their own page, and the
mention becomes an edge, only once they matter for the knowledge independent of this one
initiative.
## Authorised labels
Only `see-also` is authorised in every direction for now. The vocabulary a participation edge
(person -> project) would use is a deliberate later addition, not an oversight - adding it is a
collection-contract change made when that label exists, not a way around a refusal.
## Outbound linking
A project page links to the entities and concepts its initiative actually touches - the codebase
it ships, the system it changes, the concept it applies - and to other project pages it depends
on or was split from.
## What does not belong here
- A summary of the tracker's current task list. The tracker owns tasks; this page owns the
initiative's durable memory.
- A person's own page reached from `## Beteiligte` - see above.
- An initiative's *artifact* - the codebase, system or tool it is about. That is `entity`
(`kb/entities/COLLECTION.md`), a different page under a different type.
+6
View File
@@ -0,0 +1,6 @@
<!-- Generated by `wikitool index rebuild`. Do not hand-edit. -->
# kb/gtd/ - Index
0 page(s). Regenerated by `wikitool index rebuild`.
+2
View File
@@ -17,6 +17,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
- **Comparisons:** 1 - **Comparisons:** 1
- **Concepts:** 80 - **Concepts:** 80
- **Entities:** 72 - **Entities:** 72
- **Gtd:** 0
- **Sources:** 29 - **Sources:** 29
- **Last Updated:** 2026-09-19 - **Last Updated:** 2026-09-19
@@ -29,6 +30,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) | | `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) | | `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) | | `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
| `gtd/` | 0 | [gtd/INDEX.md](gtd/INDEX.md) |
| `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) | | `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) |
### concepts/ ### concepts/
+1 -1
View File
@@ -170,7 +170,7 @@ tools/wikitool <command> --help
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) | | `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout | | `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only | | `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks | | `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, every type the stack lists (currently `source` and `project`) having a type-spec of that name whose schema requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy | | `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
### Telemetry ### Telemetry
+29 -13
View File
@@ -58,25 +58,35 @@ ANY_DESTINATION = "any"
# and its schema requiring `raw_files:` - not the directory, not the title # and its schema requiring `raw_files:` - not the directory, not the title
# prefix, and not a word of its prose or its template. # prefix, and not a word of its prose or its template.
# #
# That is the whole anchor, and it is deliberately this small: the four page # `project` is here for the same reason, one layer up: the weekly review
# (`wikitool review`) asks `page.kind == "project"` and reads `state:` to tell
# an ongoing initiative with no next action ("stalled") from one that is
# `dormant`, `completed` or `abandoned` on purpose. Without a `project` type
# declaring `state:` the review has nothing to join a tracker project against.
#
# That is the whole anchor, and it is deliberately this small: the page
# type-specs belong to the instance (see types/type-spec.md), so anything more # type-specs belong to the instance (see types/type-spec.md), so anything more
# would be the stack reaching into a file it does not own. # would be the stack reaching into a file it does not own.
STACK_REQUIRED_TYPES = ("source",) STACK_REQUIRED_TYPES = ("source", "project")
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",)} STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",), "project": ("state",)}
def stack_required_collections() -> tuple[str, ...]: def stack_required_collection_owners() -> dict[str, str]:
"""Collection names an instance may not rename or drop. """Which stack-required type writes into each stack-required collection -
`{collection name: type name}`.
**Derived, not listed.** The required collection is whichever one the **Derived, not listed.** The required collection is whichever one the
required type writes into - so an instance that legitimately renames required type writes into - so an instance that legitimately renames
`kb/sources/` to something else, and says so in the type-spec's `base_dir:`, `kb/sources/` to something else, and says so in the type-spec's `base_dir:`,
stays consistent instead of tripping a constant that hardcoded the old name. stays consistent instead of tripping a constant that hardcoded the old name.
A second literal list would only be a copy that drifts. A second literal list would only be a copy that drifts. The one-to-many
direction (several required types sharing a collection) picks the first
type in `STACK_REQUIRED_TYPES` that claims it - two required types
legitimately sharing one `base_dir:` is not a case that has come up.
""" """
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
names: list[str] = [] owners: dict[str, str] = {}
for type_name in STACK_REQUIRED_TYPES: for type_name in STACK_REQUIRED_TYPES:
try: try:
type_path = resolver.find_type_by_name(type_name) type_path = resolver.find_type_by_name(type_name)
@@ -88,8 +98,14 @@ def stack_required_collections() -> tuple[str, ...]:
except (ValueError, OSError): except (ValueError, OSError):
continue continue
if base_dir: if base_dir:
names.append(str(base_dir).strip("/")) owners.setdefault(str(base_dir).strip("/"), type_name)
return tuple(dict.fromkeys(names)) return owners
def stack_required_collections() -> tuple[str, ...]:
"""Collection names an instance may not rename or drop - see
`stack_required_collection_owners()`, which this derives from."""
return tuple(stack_required_collection_owners().keys())
def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]: def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]:
@@ -226,15 +242,15 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
root = kb_dir if kb_dir is not None else config.KB_DIR root = kb_dir if kb_dir is not None else config.KB_DIR
issues: list[str] = [] issues: list[str] = []
required = stack_required_collections() owners = stack_required_collection_owners()
required = tuple(owners.keys())
can_label = collections_that_can_carry_labelled_edges() can_label = collections_that_can_carry_labelled_edges()
present = {path.name for path in iter_kb_collections(root)} present = {path.name for path in iter_kb_collections(root)}
for name in required: for name in required:
if name not in present: if name not in present:
issues.append( issues.append(
f"kb/{name}/ is missing - it is where the stack-required `source` type writes, " f"kb/{name}/ is missing - it is where the stack-required `{owners[name]}` type "
f"and `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all " f"writes, and wikitool depends on that collection existing by name"
f"depend on those pages existing"
) )
for collection in iter_kb_collections(root): for collection in iter_kb_collections(root):
+8 -4
View File
@@ -41,10 +41,11 @@ def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
kb.mkdir() kb.mkdir()
monkeypatch.setattr(config, "ROOT", tmp_path) monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb) monkeypatch.setattr(config, "KB_DIR", kb)
# Which collection the stack requires is *derived* from where the required # Which collections the stack requires are *derived* from where each
# `source` type writes, so these tests need the shipped `types/` reachable - # required type (`source`, `project`) writes, so these tests need the
# a fixture tree without one derives an empty requirement and would assert # shipped `types/` reachable - a fixture tree without one derives an empty
# against a rule that is not running. See conftest.use_shipped_type_specs. # requirement and would assert against a rule that is not running. See
# conftest.use_shipped_type_specs.
use_shipped_type_specs(monkeypatch) use_shipped_type_specs(monkeypatch)
conventions.reset_cache() conventions.reset_cache()
yield kb yield kb
@@ -153,6 +154,7 @@ def test_a_missing_stack_required_collection_is_reported(kb_root):
def test_a_correct_declaration_reports_nothing(kb_root): def test_a_correct_declaration_reports_nothing(kb_root):
_collection(kb_root, "sources", profile="sources", required=True) _collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
_collection(kb_root, "entities", profile="entities") _collection(kb_root, "entities", profile="entities")
assert kb_collections.declaration_issues(kb_root) == [] assert kb_collections.declaration_issues(kb_root) == []
@@ -210,6 +212,7 @@ def test_outbound_on_a_collection_that_cannot_carry_labels_is_a_finding(kb_root)
def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root): def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
_collection(kb_root, "sources", profile="sources", required=True) _collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
_authorising(kb_root, "entities", " any: [uses]") _authorising(kb_root, "entities", " any: [uses]")
assert kb_collections.declaration_issues(kb_root) == [] assert kb_collections.declaration_issues(kb_root) == []
@@ -217,4 +220,5 @@ def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
def test_a_collection_without_outbound_is_not_a_finding(kb_root): def test_a_collection_without_outbound_is_not_a_finding(kb_root):
"""Absence is the declaration `kb/sources/` makes: no authored edges here.""" """Absence is the declaration `kb/sources/` makes: no authored edges here."""
_collection(kb_root, "sources", profile="sources", required=True) _collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
assert kb_collections.declaration_issues(kb_root) == [] assert kb_collections.declaration_issues(kb_root) == []
+71
View File
@@ -335,6 +335,77 @@ def test_an_unknown_type_spec_field_is_reported(tmp_path, monkeypatch):
assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues) assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues)
def _stack_required_type_tree(tmp_path, monkeypatch, project_md: str = "", project_schema: str = ""):
"""A `types/` fixture carrying a valid `source` type-spec (copied from the
real repo, so it never drifts from what `docs verify` actually enforces)
plus whatever `project.md`/`project.schema.yaml` the caller supplies -
empty strings mean "write nothing", so a caller can exercise the
type-missing case. `check_stack_required_types()` (Gitea #123) has no
dedicated coverage otherwise: it is only ever exercised indirectly, via a
full `verify()` run against the real repo tree."""
types_dir = tmp_path / "types"
types_dir.mkdir()
for name in ("type-spec.md", "type-spec.schema.yaml", "source.md", "source.schema.yaml"):
(types_dir / name).write_text(
(config.ROOT / "types" / name).read_text(encoding="utf-8"), encoding="utf-8"
)
if project_md:
(types_dir / "project.md").write_text(project_md, encoding="utf-8")
if project_schema:
(types_dir / "project.schema.yaml").write_text(project_schema, encoding="utf-8")
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
_PROJECT_MD = (
"---\n"
"type: types/type-spec.md\n"
"name: project\n"
"description: Fixture project type\n"
"schema: types/project.schema.yaml\n"
"---\n"
)
def test_check_stack_required_types_reports_a_missing_project_type(tmp_path, monkeypatch):
"""No `project.md` at all - the type-missing case, worded to name the
type that is missing."""
_stack_required_type_tree(tmp_path, monkeypatch)
issues = docs_verify.check_stack_required_types()
assert any("name: project" in issue for issue in issues)
def test_check_stack_required_types_reports_project_schema_missing_state(tmp_path, monkeypatch):
"""A `project` type-spec exists, but its schema does not require `state:`
- the field-missing case, distinct from the type-missing one above."""
schema = (
"type: object\n"
"properties:\n"
" type: {type: string, const: 'types/project.md'}\n"
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
"required: [type]\n"
"additionalProperties: false\n"
)
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
issues = docs_verify.check_stack_required_types()
assert any("state" in issue and "project.md" in issue for issue in issues)
def test_check_stack_required_types_passes_when_both_types_satisfy_their_field(tmp_path, monkeypatch):
"""`source`/`raw_files:` and `project`/`state:` both satisfied - no issues."""
schema = (
"type: object\n"
"properties:\n"
" type: {type: string, const: 'types/project.md'}\n"
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
"required: [type, state]\n"
"additionalProperties: false\n"
)
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
assert docs_verify.check_stack_required_types() == []
def test_legacy_type_blocks_are_absent(): def test_legacy_type_blocks_are_absent():
assert docs_verify.check_legacy_type_blocks() == [] assert docs_verify.check_legacy_type_blocks() == []
+36 -1
View File
@@ -2,7 +2,8 @@ from pathlib import Path
import pytest import pytest
from chemenu import config, kb_collections from chemenu import config, kb_collections, type_resolver
from chemenu.type_resolver import TypeResolver
@pytest.fixture @pytest.fixture
@@ -65,3 +66,37 @@ def test_vendored_commonplace_contracts_are_ignored(repo):
def test_a_clean_tree_has_no_strays(repo): def test_a_clean_tree_has_no_strays(repo):
assert kb_collections.stray_collection_contracts() == [] assert kb_collections.stray_collection_contracts() == []
def test_stack_required_collections_includes_gtd_once_project_type_exists(repo, monkeypatch):
"""`stack_required_collections()` derives from wherever the required types
write, so `project`'s `base_dir: gtd` makes `gtd` required the moment that
type-spec exists - the same derivation `source` already gets from writing
to `sources` (Gitea #123)."""
real_types_dir = Path(__file__).resolve().parents[3] / "types"
types_dir = repo / "types"
for name in ("type-spec.md", "type-spec.schema.yaml"):
(types_dir / name).write_text(
(real_types_dir / name).read_text(encoding="utf-8"), encoding="utf-8"
)
(types_dir / "project.md").write_text(
"---\n"
"type: types/type-spec.md\n"
"name: project\n"
"description: Fixture project type\n"
"schema: null\n"
"base_dir: gtd\n"
"---\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=repo))
assert "gtd" in kb_collections.stack_required_collections()
def test_stack_required_collections_omits_a_type_with_no_type_spec(repo, monkeypatch):
"""No `project.md` at all - the required-types derivation must tolerate a
missing type rather than raising, so a corpus mid-adoption still lints."""
monkeypatch.setattr(config, "TYPES_DIR", repo / "types")
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=repo))
assert "gtd" not in kb_collections.stack_required_collections()
+27
View File
@@ -77,6 +77,33 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert "# gateway.example.net" in body assert "# gateway.example.net" in body
def test_new_project_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
"""Gitea #123: `state:` is required with a schema `default: active`, so it
must materialize even though the caller never sets it - the same rule
`--set entity_type=...`'s required fields already follow."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Testvorhaben", "--set", "responsibility=haus",
])
assert result.exit_code == 0, result.output
path = kb_dir / "gtd/haus/Testvorhaben.md"
assert path.exists()
fm, body = read_page(path)
assert fm["type"] == "types/project.md"
assert fm["state"] == "active"
assert fm["responsibility"] == "haus"
assert "# Testvorhaben" in body
for heading in ("Ziel", "Kontext", "Beteiligte", "Status", "Entscheidungen", "Gelerntes"):
assert f"## {heading}" in body
def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Badvorhaben", "--set", "responsibility=nichtexistent",
])
assert result.exit_code == 1
assert not list(kb_dir.rglob("Badvorhaben.md"))
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir): def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
"""Gitea #109 stops materializing an optional field's schema `default:`, """Gitea #109 stops materializing an optional field's schema `default:`,
but `tags`/`related`/`sources` are optional arrays with no `default:` at but `tags`/`related`/`sources` are optional arrays with no `default:` at
+2 -2
View File
@@ -268,8 +268,8 @@ def test_find_type_by_name_resolves_short_names():
def test_list_type_specs_finds_every_type_spec(): def test_list_type_specs_finds_every_type_spec():
names = {fm.get("name") for _, fm in resolver.list_type_specs()} names = {fm.get("name") for _, fm in resolver.list_type_specs()}
assert names == { assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction", "type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
"type-guidance", "instruction", "type-guidance",
} }
+2 -2
View File
@@ -15,8 +15,8 @@ def test_types_list_finds_all_current_type_specs():
rows = json.loads(result.output) rows = json.loads(result.output)
names = {row["name"] for row in rows} names = {row["name"] for row in rows}
assert names == { assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction", "type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
"type-guidance", "instruction", "type-guidance",
} }
+100
View File
@@ -0,0 +1,100 @@
---
type: types/type-spec.md
name: project
description: Structured type for a committed initiative (a GTD project) - goal, participants, durable status, open loops
schema: types/project.schema.yaml
subtype_field: responsibility
base_dir: gtd
page_ref_fields: [related, sources]
layout:
haus: {dir: haus, title: Haus}
finanzen: {dir: finanzen, title: Finanzen}
technik: {dir: technik, title: Technik}
---
# Project
This instance's configuration for the `project` type: its frontmatter fields as this schema
requires them, and the page skeleton `wikitool new project` scaffolds. `project` is instance-owned
end to end - it declares no `guidance:`, so this file's own prose (below) carries its authoring
contract, the same way a type an instance invents for itself always does.
## When to use
- A committed initiative in the GTD sense: something with a goal, an end state, and open loops -
a household project, a piece of paid work, a piece of volunteer work.
## When NOT to use
- **The artifact the initiative is about.** `project` names the *Vorhaben* - the undertaking - not
the thing it produces or touches. A codebase, a deployed system, a tool: that is `entity` with
`entity_type: codebase` (or `system`/`tool`/`technology`). "Ship Chemenu 7.0" is a `project`;
Chemenu the codebase is an `entity`. The two are homonyms, not the same page under two types -
see `kb/entities/COLLECTION.md` for the artifact side of that line.
- A bare task or a day-to-day commitment with no project shape of its own - that lives in the
task provider, not here (`kb/` never mirrors the tracker's momentary state, see below).
## Frontmatter
| Field | Required | Use |
|---|---:|---|
| `type` | Yes | `types/project.md` |
| `state` | Yes | One of: active, dormant, completed, abandoned - a durable characterization, not the tracker's momentary state (see `## Status` below) |
| `responsibility` | Yes | `subtype_field`, one of: haus, finanzen, technik - selects the area under `kb/gtd/` |
| `created` / `modified` | Yes | As everywhere |
| `related` | No | Titles of related pages (entities, concepts, sources, other projects) |
| `sources` | No | Titles of the source pages backing this page's claims |
| `provenance` | Yes | sourced, general or mixed - `general` is the common case: a project page is usually written by the operator from their own participation, not from an ingested source |
| `summary` | Yes | One-liner for `kb/index.md` |
## Authoring guidance
- **`## Status` is a durable characterization, never a momentary state.** "Pilotbetrieb seit
2026-03, zwei Abteilungen angebunden" - not "warte auf Freigabe". The page never summarizes the
task list; the momentary state lives in the tracker, and the two are joined only by name, never
synced (`AGENTS.md` invariant 8 - one truth about "status").
- **`## Beteiligte` carries mentions, not links.** One to two lines per person, in prose, with
**no `[[wikilink]]` and no page of their own.** A person earns a page only once they matter for
the *knowledge*, independent of this project - at that point the mention becomes an edge with a
participation label. This is a deliberate, named exception to `kb/CONTRACT.md` § "Every page
should" (link to the entities it mentions): it is held by this rule, not by a checker, so read
a page here with no person-links as conforming, not as incomplete.
## Template
The block below is page material, so it is written in this instance's KB language
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
become the headings of every page `wikitool new project` scaffolds.
```markdown
# {name}
**Status:** {state|capitalize}
**Bereich:** {responsibility|capitalize}
## Ziel
TODO: Was ist der angestrebte Endzustand? Woran erkennt man, dass dieses Vorhaben abgeschlossen ist?
## Kontext
TODO: Warum jetzt, warum überhaupt - der Hintergrund, der nicht aus dem Titel folgt.
## Beteiligte
TODO: Ein bis zwei Zeilen je Person - Erwähnung im Fließtext, kein Wikilink, keine eigene Seite.
## Status
TODO: Dauerhafte Charakterisierung des aktuellen Stands - keine Aufgabenliste, kein Momentzustand.
## Entscheidungen
TODO: Festlegungen, die dieses Vorhaben getroffen hat, und warum.
## Gelerntes
TODO: Was sich im Verlauf gezeigt hat.
```
The value behind `**Status:**` and `**Bereich:**` stays the English enum value - that is what
`search --field` filters on.
+78
View File
@@ -0,0 +1,78 @@
# YAML Schema for project type
type: object
properties:
type:
type: string
const: "types/project.md"
description: Must reference the project type-spec
state:
type: string
enum: [active, dormant, completed, abandoned]
default: active
description: >-
Durable characterization of this initiative, not the tracker's momentary
state. `active` and `dormant` both mean the initiative is ongoing -
`dormant` is a deliberate pause, so the weekly review does not flag it as
stalled the way an `active` project with no next action is flagged.
`completed` and `abandoned` both mean it is over; the distinction is for
the record, not for review behaviour.
responsibility:
type: string
enum: [haus, finanzen, technik]
description: >-
Which area of responsibility this initiative belongs to. No default -
the machine must not pick one on the author's behalf. Must match a key
in this type-spec's own `layout:`, or placement falls back to a naively
pluralized directory with no catalog title (see `types/type-spec.md`
§ Anatomy of a type).
created:
type: string
format: date
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
description: Creation date in YYYY-MM-DD format
modified:
type: string
format: date
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
description: Last modification date in YYYY-MM-DD format
related:
type: array
items:
oneOf:
- type: string
- type: object
minProperties: 1
maxProperties: 1
additionalProperties:
type: string
description: >-
Declared outbound edges. Each entry is either `<label>: <page title>` -
the label drawn from instructions/link-taxonomy.md and authorised per
destination by kb/gtd/COLLECTION.md's `outbound:` block - or a bare page
title for an edge whose label has not been declared yet.
sources:
type: array
items:
type: string
description: Source page titles that support claims on this page
provenance:
type: string
enum: [sourced, general, mixed]
default: general
description: >-
Provenance classification. `general` is the common case: a project page
is usually written by the operator from their own participation, not
from an ingested source.
summary:
type: string
description: 1-line summary for index.md
minLength: 1
required:
- type
- state
- responsibility
- created
- modified
- provenance
- summary
additionalProperties: false
+14 -8
View File
@@ -67,7 +67,7 @@ frontmatter before anyone drew it:
| Type-spec | Describes | Owned by | Ships as | | Type-spec | Describes | Owned by | Ships as |
|---|---|---|---| |---|---|---|---|
| `root: kb` (`entity`, `concept`, `source`, `comparison`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename | | `root: kb` (`entity`, `concept`, `source`, `comparison`, `project`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec`/`type-guidance` themselves | A stack artifact | The stack | Verbatim | | `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec`/`type-guidance` themselves | A stack artifact | The stack | Verbatim |
A page type-spec's frontmatter configuration and its `## Template` body are therefore the A page type-spec's frontmatter configuration and its `## Template` body are therefore the
@@ -104,13 +104,19 @@ exactly as it binds the four shipped ones: a new page type is instance-owned end
settles who may change it, not which language each half is written in - and it may declare its settles who may change it, not which language each half is written in - and it may declare its
own `guidance:` file if it wants the same shape, though nothing requires it to. own `guidance:` file if it wants the same shape, though nothing requires it to.
**What the stack still requires of the type layer is one line.** There must be a type-spec **What the stack still requires of the type layer is one line per type, and there are two of
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/` → `kb/` them today.** There must be a type-spec declaring `name: source` whose schema requires
provenance path (`sources coverage`, `[^cite-id]` resolution, `kb/provenance.md`) asks `raw_files:` — the whole `raw/` → `kb/` provenance path (`sources coverage`, `[^cite-id]`
`page.kind == "source"`, so without it nothing resolves. `docs verify` checks exactly that and resolution, `kb/provenance.md`) asks `page.kind == "source"`, so without it nothing resolves.
nothing beyond it: not the directory, not the title prefix, not a word of the prose. Which There must equally be one declaring `name: project` whose schema requires `state:` — the weekly
collection is stack-required is *derived* from where that type writes rather than listed review asks `page.kind == "project"` and reads `state:` to tell an ongoing initiative with no
separately, so renaming it stays consistent instead of tripping a hardcoded name. next action from one that is deliberately paused or over, so without it nothing to review
resolves either. `docs verify` checks exactly that, for each, and nothing beyond it: not the
directory, not the title prefix, not a word of the prose. Which collection is stack-required is
*derived* from where that type writes rather than listed separately, so renaming it stays
consistent instead of tripping a hardcoded name. The anchor stays this small on purpose — the
page type-specs belong to the instance, so anything more would be the stack reaching into a file
it does not own (`tools/chemenu/kb_collections.py`'s `STACK_REQUIRED_TYPES`).
**Quality goal:** a type-spec is the single source of truth for its type. No structural fact **Quality goal:** a type-spec is the single source of truth for its type. No structural fact
about a page type may be restated anywhere else — not in `AGENTS.md`, not in a skill, not in about a page type may be restated anywhere else — not in `AGENTS.md`, not in a skill, not in