stack: TOC-Scope auf types/ und docs/, Sprachregeln nach AGENTS.md zentralisiert, alle Templates auf Control-Plane-Sprache, --breaking akkumuliert (schliesst #99)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 36s

Files changed:
- AGENTS.md
- CHANGES.md
- ENVIRONMENT.md.template
- SOUL.md
- SOUL.md.template
- USER.md.template
- VERSION
- docs/ownership-and-templates.md
- docs/version-model.md
- instructions/CONTRACT.md
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- instructions/setup-instance.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_toc.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/toc.py
- tools/chemenu/version.py
- types/comparison.md
- types/concept.md
- types/entity.md
- types/lint-report.md
- types/source.md
This commit is contained in:
2026-09-15 16:21:02 +02:00
parent c0dc2129bb
commit c64479fe02
31 changed files with 1093 additions and 603 deletions
+3 -3
View File
@@ -166,12 +166,12 @@ tools/wikitool <command> --help
| Command | Purpose |
|---------|---------|
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body. A type-spec over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
| `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, 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 that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page. Computed from those categories rather than listed, so a file added later is in scope without a code change. `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
@@ -189,7 +189,7 @@ tools/wikitool <command> --help
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
+7 -1
View File
@@ -17,6 +17,7 @@ import json
import typer
from chemenu import toc
from chemenu.commands._util import fail
from chemenu.types_core import UnknownType, describe_type, list_types
@@ -88,4 +89,9 @@ def describe_type_command(
typer.echo("")
typer.echo("## Authoring guidance")
typer.echo(described["body"])
# A type-spec over 100 lines carries a generated table-of-contents region
# (`chemenu/toc.py`), which serves whoever opens the file. Here it would be
# noise: this command already hands over the whole body, so there is
# nothing left for a navigation aid to navigate - only markers and a list
# of headings the reader is about to see anyway.
typer.echo(toc.strip_region(described["body"]))
+11 -4
View File
@@ -204,7 +204,7 @@ def bump_command(
breaking: Optional[str] = typer.Option(
None,
"--breaking",
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
help="What stops working (recorded in CHANGES.md). Required on the bump that first escalates to a boundary crossing, optional on a later bump of the same crossing candidate - where it joins the reasons already recorded rather than replacing them. Refused on a bump that crosses nothing",
),
no_migration: Optional[str] = typer.Option(
None,
@@ -241,9 +241,16 @@ def bump_command(
version is not a drop-in replacement, whether or not any content moves -
requires `--breaking "<what stops working>"`, and on top of that either a
migration document for the new base or `--no-migration "<reason>"`. Both
lines are written into the entry once and then persist across every later
bump at the same stage: a follow-up bump need not repeat them, and passing
either on a bump that crosses nothing at all is refused.
are written into the entry and persist across every later bump at the same
stage, so a follow-up bump need not repeat them, and passing either on a
bump that crosses nothing at all is refused.
A candidate can cross the boundary more than once, and the two flags part
ways there. A further `--breaking` **joins** the reasons already recorded -
each crossing is its own thing an operator has to act on, and replacing
meant the second one silently deleted the first. A further
`--no-migration` **replaces**: whether content has to change is one
question about the candidate as a whole, not one per crossing.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
+35 -4
View File
@@ -150,9 +150,10 @@ def test_stale_regions_is_false_right_after_upsert():
def test_target_files_matches_the_documented_scope():
"""Integration check against the real repo: the scope is AGENTS.md, every
stage contract, kb/CONVENTIONS.md, every COLLECTION.md, and the flat
`instructions/**.md` form - never a `SKILL.md`."""
"""Integration check against the real repo: every agent-loaded category
AGENTS.md § File naming names - AGENTS.md itself, every stage contract,
kb/CONVENTIONS.md, every COLLECTION.md, the flat `instructions/**.md` form,
every type-spec, every `docs/` page - with `SKILL.md` the one exception."""
from chemenu import config
files = toc.target_files()
@@ -164,5 +165,35 @@ def test_target_files_matches_the_documented_scope():
assert "types/type-spec.md" in relatives
assert "instructions/CONTRACT.md" in relatives
assert "instructions/dev/version-parts.md" in relatives # flat, still instructions/**.md
assert "types/source.md" in relatives # a page type-spec, not only the stage contract
assert "docs/version-model.md" in relatives
assert not any(rel.endswith("SKILL.md") for rel in relatives)
assert not any(rel.startswith("types/") and rel != "types/type-spec.md" for rel in relatives)
def test_target_files_lists_type_spec_once_despite_two_sources():
"""`types/type-spec.md` is both a stage contract and a `types/*.md` file;
the set in `target_files` is what keeps that from being a special case."""
from chemenu import config
relatives = [f.relative_to(config.ROOT).as_posix() for f in toc.target_files()]
assert relatives.count("types/type-spec.md") == 1
def test_strip_region_removes_what_types_describe_would_otherwise_echo():
body = (
"# A type\n\n"
f"{blocks.open_marker(toc.REGION_NAME)}\n## Contents\n\n- [X](#x)\n"
f"{blocks.close_marker(toc.REGION_NAME)}\n\n## X\n\nProse.\n"
)
stripped = toc.strip_region(body)
assert toc.REGION_NAME not in stripped
assert "## Contents" not in stripped
assert "## X" in stripped and "Prose." in stripped
# The heading keeps the blank line the region used to sit in, rather than
# being welded to the line above it.
assert "# A type\n\n## X" in stripped
def test_strip_region_leaves_a_body_that_never_had_one_alone():
body = "# A type\n\n## X\n\nProse.\n"
assert toc.strip_region(body) == body
+101
View File
@@ -444,6 +444,107 @@ def test_breaking_line_sits_above_the_bumps_region(tree):
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
# --- version bump: accumulating --breaking ----------------------------------
def test_a_single_breaking_reason_stays_on_the_marker_line(tree):
"""The shape every entry written before accumulation existed carries."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved" in changes
def test_a_second_breaking_reason_joins_the_first_instead_of_replacing_it(tree):
"""The defect this exists for: the second --breaking used to delete the
first, leaving the entry promising one break while shipping two."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First crossing",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second crossing",
breaking="the import name changed", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
assert "- the feed moved" in changes and "- the import name changed" in changes
# Still the line an operator acts on first, above a list that can run long.
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
def test_repeating_a_breaking_reason_verbatim_is_a_no_op(tree):
"""So a re-run after an interrupted bump converges instead of writing the
same sentence twice."""
for title in ("First", "Second"):
version_cmd.bump_command(
major=True, minor=False, patch=False, title=title,
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
assert version_mod.breaking_reasons(section) == ["the feed moved"]
def test_breaking_reasons_reads_a_pre_accumulation_single_line_back(tree):
"""An entry written by a build that predates accumulation round-trips: it
parses as one reason, and a later bump grows it to a list rather than
rewriting what was already there."""
section = "## 2.0.0-beta.1 - 2026-09-01 - T\n\n**Breaking Change:** the feed moved\n\n"
assert version_mod.breaking_reasons(section) == ["the feed moved"]
grown = version_mod._add_breaking_reason(section, "the import name changed")
assert version_mod.breaking_reasons(grown) == ["the feed moved", "the import name changed"]
assert f"{version_mod.BREAKING_CHANGE_MARKER}\n- the feed moved\n" in grown
def test_no_migration_still_replaces_rather_than_accumulating(tree):
"""It answers one yes/no question about the candidate as a whole, so a
second answer supersedes the first - unlike --breaking beside it."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second",
breaking=None, no_migration="still nothing to change", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert changes.count(version_mod.MIGRATION_NONE_MARKER) == 1
assert "kb/ untouched" not in changes
assert "still nothing to change" in changes
def test_retracting_no_migration_leaves_an_accumulated_breaking_list_intact(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second",
breaking="the import name changed", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.3"))
assert version_mod.MIGRATION_NONE_MARKER not in changes
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
# --- version regrade --------------------------------------------------------
+84 -11
View File
@@ -20,16 +20,49 @@ the stage/collection contracts, the flat `instructions/**.md` files) is a
precedent first.
**Scope is computed, never a hand-picked list** - the same principle that
governs `wikitool` itself. `target_files()` walks the file-naming categories
AGENTS.md's own table calls agent-loaded reference material: `AGENTS.md`,
every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, and
every flat `instructions/**.md` file (a `SKILL.md` is excluded - it is loaded
whole by the harness, not previewed at a second hop; `instructions/CONTRACT.md`
§ "How much reasoning a step may carry" already treats a checklist read once
as the table of contents it replaced). Human docs (`README.md`, `CHANGES.md`,
`EVALS.md`, `INSTALL.md`, `tools/README.md`) are excluded too: the file-naming
table says they are "Never loaded by an agent as instruction," so the preview
mechanic this exists to mitigate does not apply to them.
governs `wikitool` itself. `target_files()` walks every file-naming category
AGENTS.md's own table calls agent-loaded: `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. One rule, and
exactly one exception below it - which is the whole point, because a scope
carrying several unexplained absences reads as an accident rather than a
decision, and did: `docs/` and the page type-specs sat outside it for no
recorded reason at all.
`docs/` belongs in for the reason the threshold exists. AGENTS.md § File naming
calls it "Agents and humans | By link, or on explicit request" - agent-loaded,
at a second hop, which is precisely the preview mechanic
`instructions/CONTRACT.md` § "Reference depth" treats as real. The exclusion of
the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`,
`tools/README.md`) rests on a sentence that does not stretch to cover it: those
are "Never loaded by an agent as instruction," while a `docs/` page is not
loaded *as instruction* but is very much loaded.
**The one exception is `SKILL.md`**, and the vendored skill-authoring guidance
is what puts it there rather than a judgment of ours. `commonplace/kb/work/
skill-creator-distillation/sources/claude-code-skill-creator/SKILL.md:88-99`
sets out three loading levels and places the SKILL.md body on the second - "In
context whenever skill triggers (<500 lines ideal)" - while aiming its own TOC
advice at the third, "large reference files (>300 lines)", i.e. bundled
resources. The Codex source agrees by placement: its TOC bullet sits directly
under "Keep references one level deep from SKILL.md. All reference files should
link directly from SKILL.md" (`.../codex-skill-creator/SKILL.md:221-222`).
Neither asks a skill body to carry a table of contents, because neither expects
one to be previewed. `instructions/CONTRACT.md` § "How much reasoning a step
may carry" arrives at the same place from the other side, treating a checklist
read once as the table of contents it replaced. (Our longest skill is 254
lines, so the <500 guidance costs us nothing either.)
A type-spec is loaded whole too - `tools/wikitool types describe` prints its
entire authoring body - but it is *also* read as a file, by whoever edits it,
and that is the reading the threshold is about. So it carries a region like any
other reference file, and `types_cmd` strips the region back out of what
`describe` prints: the command already hands over the whole body, so a
navigation aid into it would be noise in the output and nothing else.
The threshold's own provenance is worth recording, because the two sources
disagree: Codex says 100 lines, Claude Code says 300. This stack took the
stricter number.
"""
from __future__ import annotations
@@ -80,7 +113,13 @@ def target_files() -> list[Path]:
`instructions/<name>.md` form (`instructions/CONTRACT.md` says the `dev/`
split is orthogonal to Linked/Manual, not a different file shape), so
there is no separate rule for them to fall out of - and no hand-picking
for a future file under either to be missed.
for a future file under either to be missed. `types/` and `docs/` are
walked for the same reason: a type or a rationale page added later is in
scope by construction, not by someone remembering this function.
`types/type-spec.md` arrives twice - once as a stage contract, once from
the `types/` walk - and the set at the bottom is what makes that a
non-issue rather than something to special-case.
"""
files: list[Path] = [config.ROOT / "AGENTS.md"]
files += [config.ROOT / rel for rel in _STAGE_CONTRACTS]
@@ -96,6 +135,10 @@ def target_files() -> list[Path]:
for path in instructions_dir.rglob("*.md")
if path.name != "SKILL.md"
)
for subdir in ("types", "docs"):
directory = config.ROOT / subdir
if directory.is_dir():
files += sorted(directory.rglob("*.md"))
return sorted({f for f in files if f.is_file()})
@@ -116,6 +159,36 @@ def body_without_region(text: str) -> str:
return stripped.rstrip("\n") + "\n" if stripped else stripped
_REGION_WITH_PADDING_RE = re.compile(
r"\n*"
+ re.escape(blocks.open_marker(REGION_NAME))
+ r".*?"
+ re.escape(blocks.close_marker(REGION_NAME))
+ r"\n*",
re.DOTALL,
)
def strip_region(text: str) -> str:
"""`text` with the generated TOC region removed, for a caller that is
handing the whole body over anyway.
`types describe` is the one such caller: it prints a type-spec's entire
authoring body, so the region's markers and heading list would be noise in
its output rather than a way into anything. Distinct from
`body_without_region`, which exists to *measure* a body against the line
threshold and therefore also canonicalizes the trailing newline.
Takes the surrounding blank lines with it and puts one back, rather than
calling `blocks.strip`: that collapses the padding to a single newline,
which is the right answer when the result is about to be rebuilt from
scratch (`upsert` does exactly that) and the wrong one here, where the
output is printed as-is - it would leave the following `##` heading welded
to the paragraph above it.
"""
return _REGION_WITH_PADDING_RE.sub("\n\n", text)
def needs_toc(text: str) -> bool:
return len(body_without_region(text).splitlines()) > THRESHOLD
+82 -6
View File
@@ -583,9 +583,11 @@ def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
def _set_marker_line(section: str, marker: str, line: str) -> str:
"""Add or replace the one-line `marker ...` paragraph in `section`.
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
Used for the no-migration line, which - unlike the bumps list and unlike
the breaking-change paragraph below - is **not** accumulated: it answers
one yes/no question about the candidate as a whole ("does content have to
change?"), so a second answer replaces the first rather than joining it,
and `_clear_marker_line` is its retraction path.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
@@ -599,6 +601,73 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
return section.rstrip() + f"\n\n{line}\n"
# The breaking-change paragraph, matched whole: the marker line plus any `- `
# bullets under it. `_set_marker_line`'s `^marker.*$` reaches the first line
# only, which is exactly wrong for a form that can carry bullets beneath it.
_BREAKING_PARAGRAPH_RE = re.compile(
rf"^{re.escape(BREAKING_CHANGE_MARKER)}.*(?:\n-[ \t].*)*$", re.MULTILINE
)
def breaking_reasons(section: str) -> list[str]:
"""The breaking-change paragraph parsed back into one reason per crossing,
in written order.
Two shapes read the same way, which is what lets an entry written before
accumulation existed round-trip untouched: `**Breaking Change:** <reason>`
is one reason, and a bare `**Breaking Change:**` followed by `- ` bullets
is one reason per bullet. Same "flat while there is only one of them"
trick `_bumps_block` plays with its impact groups, and for the same
reason - the common case keeps the shape it always had.
"""
match = _BREAKING_PARAGRAPH_RE.search(section)
if not match:
return []
lines = match.group(0).splitlines()
head = lines[0][len(BREAKING_CHANGE_MARKER):].strip()
reasons = [head] if head else []
reasons += [line.strip()[2:].strip() for line in lines[1:]]
return [reason for reason in reasons if reason]
def _breaking_paragraph(reasons: list[str]) -> str:
"""Render the breaking-change paragraph from one reason per crossing.
One reason stays on the marker line - byte-identical to what every entry
written before accumulation carries. Two or more move to bullets under a
bare marker, because a single line holding two unrelated breakages reads
as one run-on sentence and an operator has to act on each separately.
"""
if len(reasons) == 1:
return f"{BREAKING_CHANGE_MARKER} {reasons[0]}"
bullets = "\n".join(f"- {reason}" for reason in reasons)
return f"{BREAKING_CHANGE_MARKER}\n{bullets}"
def _add_breaking_reason(section: str, reason: str) -> str:
"""Append `reason` to the breaking-change paragraph, or start one.
Accumulates rather than replaces: a candidate can cross the compatibility
boundary more than once (this is the normal shape of a long-running one),
and each crossing is a separate thing the operator of an existing instance
has to act on. Replacing meant the second `--breaking` silently deleted
the first - the entry then promised a single break while shipping two.
Repeating a reason verbatim is a no-op, so a re-run after an interrupted
bump converges instead of writing the same sentence twice.
"""
existing = breaking_reasons(section)
if reason in existing:
return section
paragraph = _breaking_paragraph(existing + [reason])
if existing:
return _BREAKING_PARAGRAPH_RE.sub(lambda _m: paragraph, section, count=1)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
return section[:anchor] + f"{paragraph}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{paragraph}\n"
def _clear_marker_line(section: str, marker: str) -> str:
"""Remove the one-line `marker ...` paragraph from `section`, if present.
@@ -633,10 +702,15 @@ def _update_open_candidate(
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`(impact, title)` to its machine-managed bump list, and set the
`(impact, title)` to its machine-managed bump list, and record the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
The two are recorded differently on purpose: a `breaking_reason` **joins**
whatever crossings the candidate already recorded (`_add_breaking_reason`),
a `no_migration_reason` **replaces** the single line that answers whether
content has to change (`_set_marker_line`).
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
the caller (`version_cmd.bump_command`), not here."""
@@ -653,7 +727,7 @@ def _update_open_candidate(
)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
section = _add_breaking_reason(section, breaking_reason)
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
elif migration_required:
@@ -684,7 +758,9 @@ def insert_changes_entry(
candidate still open" here.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks, plus the
compatibility boundary is crossed - the line saying what breaks (one
crossing, so the flat one-line form; a candidate that crosses again
accumulates bullets there, see `_add_breaking_reason`), plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than