stack: Typ project und Collection kb/gtd/ (#123)
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:
1 parent
3c9d669729
commit
ee24b6e5b8
20 files changed
+507
-38
No files matched your search
+37
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Generated by `wikitool index rebuild`. Do not hand-edit. -->
|
||||
|
||||
# kb/gtd/ - Index
|
||||
|
||||
0 page(s). Regenerated by `wikitool index rebuild`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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() == []
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user