Files
chemenu/tools/chemenu/commands/new_page.py
T
torbenandClaude Code e9f4759727
CI / verify (push) Successful in 6m17s
CI / pwsh (push) Successful in 1m59s
Release / release (push) Successful in 34s
AGENTS.md § Gates is the one gate list; cli_contract.GATES and docs verify hold it
The gate list contradicted itself across AGENTS.md, instructions/gates.md and the
command records' gate names. The Rebase-Review Gate is now listed, `new project`'s
exit 42 is no longer a pseudo-gate, gates.md and the explanation page link to the one
list instead of counting, and a register plus a docs verify check keep the three in step.

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/explanation/why-gates-are-code.md
- docs/reference/commands/pages.md
- docs/reference/commands/types-instructions-and-docs.md
- docs/reference/exit-codes.md
- instructions/gates.md
- tools/CONTRACT.md
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/errors.py
- tools/chemenu/mcp/server.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_git_publish.py
- tools/chemenu/tests/test_reference_doc.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-07 10:10:48 +02:00

667 lines
32 KiB
Python

"""Scaffold new wiki pages from type-spec templates.
These commands produce structurally-correct frontmatter and a body
skeleton with TODO placeholders by loading templates from type-spec files.
The prose (Description, Summary, Key Takeaways, ...) is still written by the
LLM afterwards with its normal edit tool.
The split between type-spec templates and LLM-provided prose is intentional:
type definitions (naming, frontmatter shape, directory placement, templates) are
deterministic and stored in /types/; the content is judgment and provided by the LLM.
Frontmatter defaults, enum validity, and required-ness all come from the
type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them.
A schema `default:` is materialized only for a field the schema also lists
in `required:` - an optional field's default is a reader-side assumption
(what a missing field means), and writing it into every scaffolded page
would turn that assumption into a stated claim instead (Gitea #109).
Directory placement for subtype-driven types (in the shipped specs: entity,
concept, source and project) also comes from the type-spec, via its `layout:` frontmatter (see
`TypeResolver.get_layout`) - not a hand-maintained Python dict. A subtype whose pages need a
different skeleton gets it from a file beside the type-spec, `types/<type>.<value>.md`
(`TypeResolver.page_template`), again without a code change here.
"""
from __future__ import annotations
from pathlib import Path
import datetime
from typing import Any, Dict, Optional
import re
import typer
from chemenu import cli_contract, config, tasks
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
check_path_budget,
check_target_free,
check_title,
fail,
needs_clearance,
parse_set_fields,
rel_path,
success,
)
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.frontmatter_io import write_page
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import find_project
from chemenu.type_resolver import resolver
# The one type name for which `new` also touches the task tracker (Gitea
# #126, #119 D8/D16/D31) - the same literal `chemenu.review._load_kb_projects`
# already matches `page.kind` against, and the one `docs verify`'s
# `check_stack_required_types` (`kb_collections.STACK_REQUIRED_TYPES`) makes
# sure some type-spec actually declares `name: project`.
PROJECT_TYPE_NAME = "project"
def _default_summary(summary: str) -> str:
"""Scaffold-time placeholder for an unfilled --summary, so schema
validation's `summary` minLength requirement doesn't block page creation.
Matches the same "TODO: add summary" text index_build.py already falls
back to when a page has no frontmatter summary."""
return summary.strip() or "TODO: add summary"
def _resolve_type_and_get_template(type_path: str, source_dir: Path = None, subtype: Any = None):
"""Resolve a type path, load the type-spec, and pick its template: the
subtype template `types/<stem>.<subtype>.md` where one exists, otherwise
the type-spec's own `## Template` block (Gitea #117)."""
type_spec = resolver.load_type_spec(type_path, source_dir)
template = resolver.page_template(type_spec, subtype)
return type_spec, template
def _enum_help(type_path: str, field_name: str) -> str:
"""Build a help string from the schema's own enum, so any text listing a
field's valid values can never drift from what the schema accepts."""
return f"One of: {'|'.join(resolver.get_enum(type_path, field_name))}"
def _parse_date(field_name: str, text: str) -> datetime.date:
"""A `--set <date field>=<value>` as a real date, or a refusal naming it."""
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--set {field_name}={text!r} must be YYYY-MM-DD.")
def _build_frontmatter(
type_path: str, schema: Optional[Dict[str, Any]], today: datetime.date, explicit: Dict[str, Any]
) -> Dict[str, Any]:
"""Build a page's frontmatter dict in schema-declaration order.
`explicit` supplies every CLI-derived value the caller already has;
fields not in `explicit` get a type-appropriate default (today's date for
date-formatted fields, the scaffold placeholder for `summary`, the
schema's own `default:` where declared *and the field is required*, an
empty list for arrays), or are omitted entirely if optional with no
sensible default (e.g. `source_url`). This is what lets frontmatter
shape - and scaffold-time defaults like `provenance: general` - follow
the schema instead of being hand-declared per CLI command.
A `default:` on an *optional* field (e.g. `instruction.obligation`) is
deliberately not materialized here: it documents what a reader should
assume when the field is absent, not what the scaffold should write.
Writing it anyway turned every scaffolded instruction into one that
falsely claims `obligation: required` - a migration-only field - and
the same read/write distinction is what the schema's own `default:`
doc-comment (`types/instruction.schema.yaml`) already draws (Gitea
#109).
"""
required = set((schema or {}).get("required") or [])
frontmatter: Dict[str, Any] = {"type": type_path}
for field_name, field_schema in (schema or {}).get("properties", {}).items():
if field_name == "type":
continue
if field_name in explicit:
value = explicit[field_name]
# A `--set date=2026-08-23` arrives as a string; the corpus stores
# dates as `datetime.date`, and the schema already says which fields
# those are. Converting here keeps one representation on disk instead
# of leaving it to whoever wrote the CLI call.
if field_schema.get("format") == "date" and isinstance(value, str):
value = _parse_date(field_name, value)
frontmatter[field_name] = value
elif field_name == "summary":
frontmatter[field_name] = _default_summary("")
elif field_name == "author":
resolved_author = config.default_author()
if resolved_author is None:
fail(
"No author configured for this instance. Set `git config user.name`, "
"or export WIKI_AUTHOR to override it, then retry."
)
frontmatter[field_name] = resolved_author
elif field_schema.get("format") == "date":
frontmatter[field_name] = today
elif "default" in field_schema and field_name in required:
frontmatter[field_name] = field_schema["default"]
elif field_schema.get("type") == "array":
frontmatter[field_name] = []
# Carry through any caller-supplied field the schema doesn't declare,
# rather than silently dropping it: a typo'd `--set` must surface as a
# validation error (via `additionalProperties: false`) instead of being
# quietly ignored, and a schema that does allow extra properties should
# keep them.
for field_name, value in explicit.items():
if field_name not in frontmatter:
frontmatter[field_name] = value
return frontmatter
def _filter_bullets(value: Any) -> str:
"""Render a list frontmatter field as `- [[item]]` bullet lines."""
items = value or []
return "\n".join(f"- [[{item}]]" for item in items) if items else "- None identified"
def _filter_join(value: Any) -> str:
"""Comma-join a list frontmatter field."""
return ", ".join(value or [])
def _filter_capitalize(value: Any) -> str:
return str(value).capitalize()
def _filter_table_header(value: Any) -> str:
"""Render an array field as wikilinked markdown table column headers."""
return " | ".join(f"[[{item}]]" for item in (value or []))
def _filter_table_sep(value: Any) -> str:
"""Render the markdown table separator row for an array field, one
column per item."""
return "|".join("--------" for _ in (value or [])) or "--------"
def _filter_table_cells(value: Any) -> str:
"""Render a placeholder table body row, one cell per array item."""
return " | ".join("..." for _ in (value or []))
_TEMPLATE_FILTERS = {
"bullets": _filter_bullets,
"join": _filter_join,
"capitalize": _filter_capitalize,
"table_header": _filter_table_header,
"table_sep": _filter_table_sep,
"table_cells": _filter_table_cells,
}
def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""Apply variable substitutions to a template string.
Supports:
- `{field}` - plain substitution from `variables[field]`
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
to `variables[field]`'s value, so templates can render list/enum
frontmatter fields directly instead of the caller precomputing a
separate display-only variable for each one
- `{field|literal text}` - literal fallback if `field` isn't in
`variables` at all and the suffix isn't a recognized filter name
"""
def replace_match(match: re.Match) -> str:
full_match = match.group(0)
var_name = match.group(1)
if '|' in var_name:
var_name, suffix = var_name.split('|', 1)
var_name = var_name.strip()
suffix = suffix.strip()
if var_name not in variables:
return suffix
value = variables[var_name]
filter_fn = _TEMPLATE_FILTERS.get(suffix)
return filter_fn(value) if filter_fn else str(value)
return str(variables.get(var_name, full_match))
pattern = r'\{([^}]+)\}'
return re.sub(pattern, replace_match, template)
def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]:
"""Return the subtype-driven subdirectory under a type's `base_dir`, from
the type-spec's own `layout:` frontmatter. Thin wrapper over
`TypeResolver.subtype_dir` - the one place this computation lives, shared
with `move` and `lint`'s misplaced-page finding."""
return resolver.subtype_dir(type_path, subtype)
def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path:
"""Resolve where an instance of this type is written: `<root>/<base_dir>`,
plus a subtype subdirectory when the type declares a `layout:`.
Thin wrapper over `TypeResolver.compute_target_dir` - the one placement
rule, also used by `move` and `lint`'s misplaced-page finding -
converting its `ValueError` into the CLI's normal friendly-failure path.
`base_dir` is resolved against `config.KB_DIR` by default, so tests that
point KB_DIR at a temporary fixture wiki can never write into the real
`kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT`
instead - for artifacts that are agent-directed material rather than
knowledge, and so live outside the knowledge layer."""
try:
return resolver.compute_target_dir(type_path, frontmatter)
except ValueError as exc:
fail(str(exc))
def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None:
"""Validate frontmatter against its type-spec schema, converting a
ValueError into the CLI's normal friendly-failure path instead of an
uncaught traceback. The schema's own error message (which already names
the offending field and, for enums, lists the valid values) is shown
as-is - there is no separate hand-maintained validity check to keep in
sync with it."""
try:
resolver.validate_frontmatter(frontmatter, type_path, source_dir)
except ValueError as exc:
fail(str(exc))
def _load_type_or_fail(type_path: str, source_dir: Path, subtype: Any = None):
"""Resolve a type path and load its type-spec + template, converting an
unresolvable/invalid `--type` into the CLI's normal friendly-failure path."""
try:
return _resolve_type_and_get_template(type_path, source_dir, subtype)
except ValueError as exc:
fail(str(exc))
def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"""Step 1+2 of `new project` (Gitea #126, #119 D8/D31): make sure a
tracker project named `page_title` exists before the caller writes the
kb/ page for it, and never touch the page itself.
Returns a one-line status to fold into the success message, or `None`
when no tracker is configured at all - `page_title` is the page's actual
title (title_prefix already applied), the same value `check_collision`
checked against `kb/` moments earlier, because it is what the join in
`chemenu.review` keys on (#119 D8: the name is the sole coupling).
Leaves through `fail()`/`needs_clearance()` (never returns) for every
outcome that must not proceed to page creation - a collision, a
dependency failure, or a human still owing the manual step - so that by
the time this returns normally, either nothing was created (page-only or
a genuine refusal) or a tracker project now provably exists for this
exact name, and the caller's next step is the only one left: write the
page.
"""
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
return None
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
# E.g. a superproductivity instance with access: "snapshot" (Gitea
# #133) - the tracker is read-only from here, so this refuses before
# either the collision check or the page write, exactly like any
# other precondition failure.
fail(str(exc))
existing = find_project(reader, page_title)
if existing is not None:
if resume:
return f"tracker project '{existing.name}' already existed (--resume)"
fail(
f"A project named '{page_title}' (case-insensitively) already exists in the "
f"tracker ('{existing.name}') - nothing was created (neither the tracker project "
"nor the kb/ page). If an earlier run of this exact command asked you to create it "
"by hand and you just did, re-run with --resume to continue to page creation "
"instead of being refused here."
)
try:
writer.create_project(page_title)
except HumanInterventionRequired as exc:
needs_clearance(
f"{exc}\n\nNothing was created yet for '{page_title}' (neither the tracker "
"project nor the kb/ page). Once you have done the above, re-run this exact "
"command with --resume to verify it and continue to page creation - do not assume "
"confirming here is enough."
)
except ValidationError as exc:
fail(str(exc))
return f"tracker project '{page_title}' created"
@cli_contract.record(cli_contract.CommandRecord(
path="new",
summary="Scaffold a new wiki page of any type.",
synopsis=(
cli_contract.Variant(
usage='new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]',
notes="Any type; its type-spec decides fields, directory, title prefix and template.",
),
cli_contract.Variant(
usage='new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] '
"[--set related=X,Y] [--set sources=\"Source - Z\"] "
"[--set provenance=sourced|general|mixed]",
notes="Writes `kb/entities/<subdir>/<Name>.md` - `<subdir>` from `entity_type` "
"via the type-spec's `layout:`",
),
cli_contract.Variant(
usage='new concept --name "<Name>" --set concept_type=<t> ...',
notes="Writes `kb/concepts/<subdir>/<Name>.md` - `<subdir>` from `concept_type` "
"via the type-spec's `layout:`",
),
cli_contract.Variant(
usage='new source --name "<Name>" --set source_type=<t> '
"--set raw_files=raw/notes/x.md,raw/notes/y.md --set fidelity=<f> --set authority=<a> "
"[--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]",
notes="Writes `kb/sources/<subdir>/Source - <Name>.md` (prefix added automatically; "
"`<subdir>` from `source_type` via the type-spec's `layout:`) with a `raw_files:` "
"list; rejects paths that don't exist",
),
cli_contract.Variant(
usage='new comparison --name "X vs Y" --set entities=X,Y',
notes="Writes `kb/comparisons/X vs Y.md`",
),
cli_contract.Variant(
usage='new project --name "<Name>" --set responsibility=<bereich> [--resume]',
notes="Writes `kb/gtd/<bereich>/<Name>.md` and, with a task tracker configured, a "
"same-named tracker project",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="`new <type>`: Yes - single file write. `new project`: **No** for the "
"tracker-configured case - a tracker-project write (or its human-clearance request) "
"happens before the kb/ page write, so a failure between the two leaves a tracker "
"project with no page (a state `review`'s check 3 already reports), never a page with "
"no tracker project. Still a single file write when no tracker is configured",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"A title becomes a file name, so it must be valid and unique on Windows and macOS as "
"well as Linux, whichever platform runs the command and whichever root the type writes "
"to. The rule is `kb/CONTRACT.md` § Titles are identifiers; it is checked on the full "
"title, after `title_prefix`.",
"The target's path below the instance root may be at most 160 characters, counted in "
"UTF-16 code units the way Windows counts MAX_PATH, so a Windows checkout without "
"long paths keeps working. A longer one is refused, for every root, naming the length "
"and how much shorter it has to get.",
"`new` never overwrites: a file already at the target - or one a case-insensitive file "
"system would treat as the same file - is refused for every root, `instructions/` "
"included.",
"The type-spec drives everything: fields, directory (`base_dir`/`layout`), title "
"prefix, and template. `types list`/`types describe` show what a type requires.",
"The body skeleton is `types/<type>.<value>.md` when that file exists, `<value>` being "
"the page's subtype field value (e.g. `types/entity.person.md` for `entity_type=person`) "
"- it replaces the type-spec's `## Template` block whole. Without such a file, and for a "
"type with no subtype field, the `## Template` block is used as before.",
"A schema `default:` is materialized only for a field the schema also lists in "
"`required:`.",
"`--set` is repeatable, and comma-separated values fill array fields. An element that "
"itself contains a comma is written `\\,`, or passed as its own repeated `--set` for "
"that field - repeating an array field appends.",
"A capture field the type-spec requires (a source's `fidelity`/`authority`) must be "
"passed with `--set`; `new` never guesses it and refuses `unknown` for it.",
"Produces structurally correct frontmatter and a body skeleton only - the prose "
"(Description, Summary, judgment calls about relationships) is written afterwards.",
"`new project`: with `.wikitool-tasks.json` configuring a task tracker, also makes sure "
"a same-named tracker project exists - one name, one identity. No tracker configured is "
"a legitimate, explicitly announced state: page only.",
"`new project`: tracker before page. The tracker side is settled first, so a failure "
"past that point leaves a tracker project with no page - a state `review`'s check 3 "
"reports - never a page with no tracker project.",
"`new project`: a name already taken, case-insensitively, in `kb/` or the tracker is "
"refused outright, naming where it was found, and creates nothing. For `caldav` the "
"tracker check covers every list in the account, not only the ones counted as "
"projects.",
"`new project`: a provider whose configured access path has no write path (Super "
"Productivity's `access: \"snapshot\"`) refuses entirely with exit 1, naming the "
"`access: \"api\"` instance to use instead - neither the tracker project nor the page "
"is created, and `--resume` behaves the same.",
"`new project`: a provider that could write but has no project-creation call of its own "
"(Super Productivity's `access: \"api\"` - `GET /projects` exists, `POST /projects` "
"does not) prints instructions for a human and exits 42, creating nothing. That is not "
"one of the named gates, but the same exit code and the same handling. `caldav` never "
"does this: `MKCALENDAR` creates the list, so a valid, non-colliding name always "
"creates it.",
"`--resume` is how a later run tells the command a human has done what that message "
"asked: it re-verifies through the tracker's read path before continuing to page "
"creation, rather than trusting the claim, and exits 42 again if the tracker still does "
"not have the project. `--resume` on any other type is refused.",
),
failures=(
cli_contract.Failure(
cause="A page with this title already exists, the type is unknown, or a `--set` "
"value is invalid",
reaction="Not transient - fix the argument and retry once",
),
cli_contract.Failure(
cause="The title is not a valid file name (forbidden character, control "
"character, reserved name such as `CON` or `Index`, trailing dot or space, empty), "
"collides with another page by case or Unicode normalization, the target file "
"already exists, or the target path is over the 160-character path budget",
reaction="Not transient - choose another (for the budget: a shorter) title and retry "
"once. Nothing was created, "
"and for `new project` no tracker project either",
),
cli_contract.Failure(
cause="A `raw_files` path does not exist",
reaction="Not transient - fix the path and retry once",
),
cli_contract.Failure(
cause="A capture field the type-spec requires is missing, or set to `unknown`",
reaction="Pass it explicitly (e.g. `--set fidelity=verbatim --set "
"authority=reporting`), then retry once",
),
cli_contract.Failure(
cause="`--resume` with a type other than `project`",
reaction="Drop `--resume` and retry once",
),
cli_contract.Failure(
label="new project",
cause="The name is already taken in the tracker (case-insensitively; for `caldav` "
"against every list in the account)",
reaction="Not transient - choose another name. If an earlier run of this exact "
"command asked a human to create the project and they did, re-run with `--resume`",
),
cli_contract.Failure(
label="new project",
cause="The configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`)",
reaction="Not transient - point at the `access: \"api\"` instance the error names. "
"A `--resume` retry refuses the same way, since nothing about the config changes by "
"asking again",
),
cli_contract.Failure(
label="new project",
cause="The page write failed after the tracker project was confirmed to exist",
reaction="Fix the write error, then re-run with `--resume` - a plain re-run is "
"refused as a tracker collision",
),
cli_contract.Failure(
label="new project",
cause="The provider cannot create the project itself (Super Productivity's "
"`access: \"api\"`); the output says what a human has to create",
reaction="Show the user the command's full output verbatim and stop. Once they have "
"created the project, re-run the same command with `--resume`; it re-verifies and "
"exits 42 again, unchanged, if the tracker still does not have it",
code=42,
),
),
examples=(
'tools/wikitool new entity --name "Docker" --set entity_type=tool --set tags=containers',
'tools/wikitool new source --name "Docker Cheatsheet" '
"--set raw_files=raw/2026/09/docker-cheatsheet.md --set fidelity=verbatim "
"--set authority=reporting",
'tools/wikitool new project --name "Homelab migration" --set responsibility=infrastruktur '
"--resume # re-run after exit 42, once the user created the tracker project",
),
never=(
"Never hand-craft the page, or its frontmatter, instead.",
),
see_also=(
"`wikitool types describe <type>` - what a type requires and where it lands",
"`wikitool touch` - changes a page's own frontmatter afterwards",
"`wikitool task new` - a tracker item without a page",
"`docs/explanation/knowledge-and-commitment.md` - why a project is a page and a tracker project",
),
))
def new_page_command(
type_name: str = typer.Argument(
...,
help="Type name, e.g. entity|concept|source|comparison (see `wikitool types list`)",
),
name: str = typer.Option(..., "--name", help="Page title (a type's title_prefix is added automatically)"),
type_path_override: str = typer.Option(
"", "--type", help="Override the type-spec path (defaults to the one named by TYPE_NAME)"
),
set_fields: Optional[list[str]] = typer.Option(
None,
"--set",
help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing",
),
resume: bool = typer.Option(
False,
"--resume",
help="`project` only: confirm a human has completed the manual tracker step an earlier "
"HumanInterventionRequired refusal asked for, so this run continues to page creation "
"instead of refusing the now-existing tracker project as a collision.",
),
):
"""Scaffold a new wiki page of any type.
\f
The type's own type-spec drives everything: which frontmatter fields
exist and are required (its `.schema.yaml`), their scaffold defaults
(schema `default:`), where the page is written (`base_dir` + `layout`),
what prefixes its title (`title_prefix`), and its body skeleton (the
type-spec's template). Adding a new type therefore needs no change here.
For `type_name == "project"` specifically, this also ensures a
same-named tracker project exists before the page is written - see
`_ensure_tracker_project`.
"""
type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path:
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}")
try:
is_project = resolver.get_type_name(type_path) == PROJECT_TYPE_NAME
except ValueError as exc:
fail(str(exc))
if resume and not is_project:
fail("--resume only applies to `new project` (Gitea #126) - it has no effect on any other type.")
today = datetime.date.today()
try:
title_prefix = resolver.get_title_prefix(type_path)
root = resolver.get_root(type_path)
except ValueError as exc:
fail(str(exc))
page_title = f"{title_prefix}{name}"
# The title becomes a file name wherever the type writes, so the rule holds
# for every root - a page under `instructions/` is checked out on Windows too.
check_title(page_title)
if root == "kb":
# Title collisions matter because wikilinks resolve by title alone, so
# two pages sharing a stem are indistinguishable to every link in the
# wiki. Artifacts outside kb/ are not addressed by title and are not
# part of that namespace, so the check does not apply to them.
check_collision(page_title)
schema = resolver.get_schema(type_path)
explicit = parse_set_fields(set_fields, schema)
declared = (schema or {}).get("properties", {})
if "summary" in declared:
explicit.setdefault("summary", _default_summary(""))
if "name" in declared:
# The CLI already has this value; a type that stores its own name in
# frontmatter should not have to be told it twice.
explicit.setdefault("name", name)
if "description" in declared:
explicit.setdefault("description", "TODO: add description")
frontmatter = _build_frontmatter(type_path, schema, today, explicit)
# Capture fields (Gitea #67, e.g. `fidelity`/`authority` on a source page)
# are deliberately absent from `required:` - putting them there would make
# every existing instance's source pages stop validating, a
# boundary-crossing change (version-parts.md). The requirement is instead
# enforced here, in the tool, exactly like `source_type`'s no-default
# refusal (#66) reads from the schema alone: without a `default:` and
# omitted from `explicit`, a capture field simply never lands in
# `frontmatter`, so its absence has to be caught before it is silently
# written as a page with no capture record at all.
try:
capture_fields = resolver.get_capture_fields(type_path)
except ValueError as exc:
fail(str(exc))
missing_capture = [f for f in capture_fields if not frontmatter.get(f)]
if missing_capture:
fail(
f"Type {type_path} requires capture field(s) {', '.join(missing_capture)} - pass them "
"explicitly, e.g. --set fidelity=verbatim --set authority=reporting. "
"new does not guess them (see raw/CONTRACT.md)."
)
guessed_unknown = [f for f in capture_fields if frontmatter.get(f) == "unknown"]
if guessed_unknown:
fail(
f"Capture field(s) {', '.join(guessed_unknown)} cannot be set to 'unknown' here - that "
"value is backfill-only, written only by `wikitool touch` on a page predating this rule."
)
target_dir = _target_dir(type_path, frontmatter)
# Validated before the template is picked: the subtype value names a file
# under types/, so it has passed the schema's enum before it is used as one.
_validate_or_fail(frontmatter, type_path, target_dir)
try:
subtype_field = resolver.get_subtype_field(type_path)
except ValueError as exc:
fail(str(exc))
subtype = frontmatter.get(subtype_field) if subtype_field else None
_type_spec, template = _load_type_or_fail(type_path, target_dir, subtype)
if "raw_files" in frontmatter:
check_raw_files_exist(frontmatter["raw_files"])
path = target_dir / f"{page_title}.md"
check_path_budget(path, "Choose a shorter title.")
check_target_free(path)
body = _apply_template_variables(
template,
{
**frontmatter,
"name": name,
"today": today.isoformat(),
},
)
# Everything above only validates - nothing has touched disk or the
# tracker yet. Tracker before page (Gitea #126's own "Reihenfolge ist die
# Fehlerbehandlung"): a page-write failure past this point leaves a
# tracker project with no page, a state check 3 (#125) already reports;
# the reverse order would instead leave a kb/ page claiming an
# initiative nobody can act on, which is worse and unreported.
tracker_note = _ensure_tracker_project(page_title, resume=resume) if is_project else None
try:
write_page(path, frontmatter, body)
except OSError as exc:
if is_project and tracker_note is not None:
fail(
f"Could not write {rel_path(path)} ({exc}). The kb/ page was NOT created, but "
f"the tracker project was already confirmed to exist ({tracker_note}). Fix the "
"write error and re-run with --resume to finish - a plain re-run would otherwise "
"be refused as a tracker collision."
)
raise
msg = f"Created {rel_path(path)}"
if is_project:
msg += f" ({tracker_note or 'no task tracker configured - page only'})"
success(msg)