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
**Author:** Torben Nehmer
+8 -2
View File
@@ -95,6 +95,7 @@ chemenu/
│ ├── concept.md # Concept type config + template (+ .guidance.md)
│ ├── source.md # Source 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`
│ └── lint-report.md # Contract-only: describes reports/, owns no directory
├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection
@@ -103,7 +104,7 @@ chemenu/
│ ├── log.md # Generated chronological audit log
│ ├── provenance.md # Generated raw-file reverse index
│ ├── entities/ # COLLECTION.md + INDEX.md + areas below
│ │ ├── projects/
│ │ ├── codebases/
│ │ ├── systems/
│ │ ├── tools/ # own INDEX.md once past 50 pages
│ │ ├── technologies/
@@ -123,7 +124,11 @@ chemenu/
│ │ ├── notes/
│ │ ├── trackers/
│ │ └── 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
│ └── CONTRACT.md # Run keys, required files, how a run closes
├── reports/ # DERIVED: lint reports, traces, eval scores. Gitignored
@@ -467,6 +472,7 @@ The LLM will create and maintain:
- Entity pages in `kb/entities/`
- Concept pages in `kb/concepts/`
- Comparison pages in `kb/comparisons/`
- Project (Vorhaben) pages in `kb/gtd/`
- Lint reports, session traces and eval scores in `reports/` (gitignored)
## 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 |
|------------|-------|----------|
| `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/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/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
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
+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
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
parts that become page text: each one's `## Template` block, and the `layout:` titles that head a
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`,
`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
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.
+1
View File
@@ -5,6 +5,7 @@ outbound:
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also]
sources: [evidenced-by, defined-in, see-also]
comparisons: [compares-with, see-also]
gtd: [see-also]
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
- **Concepts:** 80
- **Entities:** 72
- **Gtd:** 0
- **Sources:** 29
- **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) |
| `concepts/` | 80 | [concepts/INDEX.md](concepts/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) |
### 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 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 |
| `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 |
### Telemetry
+29 -13
View File
@@ -58,25 +58,35 @@ ANY_DESTINATION = "any"
# and its schema requiring `raw_files:` - not the directory, not the title
# 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
# would be the stack reaching into a file it does not own.
STACK_REQUIRED_TYPES = ("source",)
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",)}
STACK_REQUIRED_TYPES = ("source", "project")
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",), "project": ("state",)}
def stack_required_collections() -> tuple[str, ...]:
"""Collection names an instance may not rename or drop.
def stack_required_collection_owners() -> dict[str, str]:
"""Which stack-required type writes into each stack-required collection -
`{collection name: type name}`.
**Derived, not listed.** The required collection is whichever one the
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:`,
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
names: list[str] = []
owners: dict[str, str] = {}
for type_name in STACK_REQUIRED_TYPES:
try:
type_path = resolver.find_type_by_name(type_name)
@@ -88,8 +98,14 @@ def stack_required_collections() -> tuple[str, ...]:
except (ValueError, OSError):
continue
if base_dir:
names.append(str(base_dir).strip("/"))
return tuple(dict.fromkeys(names))
owners.setdefault(str(base_dir).strip("/"), type_name)
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]:
@@ -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
issues: list[str] = []
required = stack_required_collections()
owners = stack_required_collection_owners()
required = tuple(owners.keys())
can_label = collections_that_can_carry_labelled_edges()
present = {path.name for path in iter_kb_collections(root)}
for name in required:
if name not in present:
issues.append(
f"kb/{name}/ is missing - it is where the stack-required `source` type writes, "
f"and `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all "
f"depend on those pages existing"
f"kb/{name}/ is missing - it is where the stack-required `{owners[name]}` type "
f"writes, and wikitool depends on that collection existing by name"
)
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()
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb)
# Which collection the stack requires is *derived* from where the required
# `source` type writes, so these tests need the shipped `types/` reachable -
# a fixture tree without one derives an empty requirement and would assert
# against a rule that is not running. See conftest.use_shipped_type_specs.
# Which collections the stack requires are *derived* from where each
# required type (`source`, `project`) writes, so these tests need the
# shipped `types/` reachable - a fixture tree without one derives an empty
# requirement and would assert against a rule that is not running. See
# conftest.use_shipped_type_specs.
use_shipped_type_specs(monkeypatch)
conventions.reset_cache()
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):
_collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
_collection(kb_root, "entities", profile="entities")
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):
_collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
_authorising(kb_root, "entities", " any: [uses]")
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):
"""Absence is the declaration `kb/sources/` makes: no authored edges here."""
_collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "gtd", profile="none", required=True)
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)
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():
assert docs_verify.check_legacy_type_blocks() == []
+36 -1
View File
@@ -2,7 +2,8 @@ from pathlib import Path
import pytest
from chemenu import config, kb_collections
from chemenu import config, kb_collections, type_resolver
from chemenu.type_resolver import TypeResolver
@pytest.fixture
@@ -65,3 +66,37 @@ def test_vendored_commonplace_contracts_are_ignored(repo):
def test_a_clean_tree_has_no_strays(repo):
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
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):
"""Gitea #109 stops materializing an optional field's schema `default:`,
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():
names = {fm.get("name") for _, fm in resolver.list_type_specs()}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
"type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
"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)
names = {row["name"] for row in rows}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
"type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
"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 |
|---|---|---|---|
| `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 |
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
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
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/` → `kb/`
provenance path (`sources coverage`, `[^cite-id]` resolution, `kb/provenance.md`) asks
`page.kind == "source"`, so without it nothing resolves. `docs verify` checks exactly that 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.
**What the stack still requires of the type layer is one line per type, and there are two of
them today.** There must be a type-spec declaring `name: source` whose schema requires
`raw_files:` — the whole `raw/` → `kb/` provenance path (`sources coverage`, `[^cite-id]`
resolution, `kb/provenance.md`) asks `page.kind == "source"`, so without it nothing resolves.
There must equally be one declaring `name: project` whose schema requires `state:` — the weekly
review asks `page.kind == "project"` and reads `state:` to tell an ongoing initiative with no
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
about a page type may be restated anywhere else — not in `AGENTS.md`, not in a skill, not in