From 72b2b4424f7daadc95aa1e858187e61d58c28fff Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Fri, 4 Sep 2026 15:55:04 +0200 Subject: [PATCH] docs verify: DEVELOPMENT.md in STAGE_READMES; self-labelling release notes; prose corrections to 4.6.0 (#47 Block 3) Files changed: - .gitea/workflows/release.yml - CHANGES.md - VERSION - instructions/dev/stack-close/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/git_publish.py - tools/chemenu/tests/test_docs_verify.py --- .gitea/workflows/release.yml | 21 ++++++++++ CHANGES.md | 56 +++++++++++++++++++++++++ VERSION | 2 +- instructions/dev/stack-close/SKILL.md | 13 +++++- tools/CONTRACT.md | 2 +- tools/chemenu/commands/docs_verify.py | 12 +++++- tools/chemenu/commands/git_publish.py | 19 +++++---- tools/chemenu/tests/test_docs_verify.py | 24 +++++++++++ 8 files changed, 137 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 69b5630..a773310 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -102,11 +102,32 @@ jobs: - name: Release notes from CHANGES.md # `version notes` fails when the changelog has no entry for this # version, which is the last place that mistake can still be caught. + # + # The footer below settles Gitea #47's second side-finding: a release + # note is written once, at tag time, and a later correction to + # CHANGES.md never reaches it - `gitea-mcp` has no release-edit method, + # and delete-and-recreate would destroy the attached tarball assets that + # INSTALL.md and `version check` point at. That happened for real to + # v4.4.0, whose note carried a fact that the corpus had already + # corrected. Rather than build a correction path for a text nobody can + # edit, the snapshot says it is one and names where the maintained + # version lives. A stale note then costs a reader one click instead of + # a wrong belief. Appended here rather than inside `version notes`, + # which is a general-purpose extractor whose other callers (a local + # preview, a pipe) should not inherit a release-page footer. if: steps.version.outputs.skip != 'true' run: | set -eu tools/wikitool docs verify tools/wikitool version notes > /tmp/release-notes.md + cat >> /tmp/release-notes.md <<'EOF' + + --- + + *This note is a snapshot of the `CHANGES.md` entry as it stood when the tag was cut, and + is never edited afterwards. The maintained version of this text - including any later + correction - is the entry for this version in `CHANGES.md` in the repository.* + EOF cat /tmp/release-notes.md - name: Build the distribution tarball diff --git a/CHANGES.md b/CHANGES.md index 701d9e7..fa2c05b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,6 +35,62 @@ dev-checkout concern - readable here, never shipped as something to parse. --- +## 4.6.1 - 2026-09-04 - DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3) + +**Author:** Torben Nehmer + + +- DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3) + + +Block 3 aus #47 - die beiden Nebenbefunde entschieden und umgesetzt - plus drei Prosa-Korrekturen +an `4.6.0`, die eine Bewertung des eigenen Ergebnisses gefunden hat. + +**`DEVELOPMENT.md` gehört in `docs_verify.STAGE_READMES`** (entschieden: ja). Das Gegenargument +bei der Aufnahme war, die Liste führe bisher nur ausgelieferte Dokumente, und `DEVELOPMENT.md` +wird von `dist_cmd.ROOT_FILES` bewusst nicht ausgeliefert. Beim Hinsehen löst es sich auf: +`check_readmes_have_no_command_table` überspringt eine Datei, die nicht existiert. In einer +ausgelieferten Instanz ist der Eintrag damit schlicht wirkungslos, im Entwicklungs-Checkout - dem +einzigen Ort, an dem die Datei existiert und also driften kann - greift er. Dafür spricht der +Anlass: genau diese Datei trug einmal eine Tabelle, die für jeden Verify-Befehl ein zweites Mal +beschrieb, was er prüft, und sie musste von Hand entfernt werden, weil nichts sie mit etwas +verglich. Zwei Tests: einer, der die Tabelle in `DEVELOPMENT.md` meldet, und einer, der +festhält, dass eine fehlende gelistete Datei übersprungen und nicht als Fund gemeldet wird - der +Instanz-Fall, an dem die Entscheidung hing. Der Konstantenname ist jetzt enger als sein Inhalt; +das steht als Kommentar daneben, statt eine Umbenennung durch zwei Aufrufstellen zu ziehen. + +**Veröffentlichte Release-Notes veralten weiter - sie sagen es jetzt selbst** (entschieden: +Schnappschuss akzeptieren, statt einen Korrekturweg zu bauen). Eine nach dem Tag korrigierte +`CHANGES.md` erreicht die Release-Seite nicht: `gitea-mcp` kennt kein Release-Edit, und +Löschen-und-neu-Anlegen würde die angehängten Tarball-Assets vernichten, auf die `INSTALL.md` und +`version check` zeigen. Bei `v4.4.0` ist das real eingetreten. Statt eines Korrekturwegs für einen +Text, den niemand editieren kann, trägt der Schnappschuss jetzt eine Fußzeile, die sagt, dass er +einer ist und wo die gepflegte Fassung liegt - eine veraltete Notiz kostet einen Leser damit einen +Klick statt einer falschen Überzeugung. Angehängt in `release.yml` und nicht in `version notes`: +das Kommando ist ein allgemeiner Extraktor, dessen andere Aufrufer (lokale Vorschau, eine Pipe) +keine Release-Seiten-Fußzeile erben sollen. `.gitea/`-Änderung, also ohne eigenen Bump-Anspruch - +sie fährt hier mit. + +**Drei Prosa-Korrekturen an `4.6.0`.** Der `4.6.0`-Eintrag und der Docstring von +`touches_stack_machinery` behaupteten, das Prädikat prüfe „denselben Pfad-Umfang, den der +CI-Versions-Gate selbst verwendet". Das stimmt nicht: CI matcht `[^/]+/CONTRACT\.md$`, also genau +eine Pfadebene, das Prädikat matcht `CONTRACT.md` in jeder Tiefe. Folgenlos im Verhalten - ein +Über-Match druckt eine Zeile zu viel, nie eine zu wenig -, aber es war eine behauptete Äquivalenz, +die keine ist, geschrieben in genau der ungeprüften Prosa-Phase, um die #47 sich dreht. Docstring +und `tools/CONTRACT.md` benennen die Differenz jetzt und begründen sie (bei einer Erinnerung ist +Über-Matchen die richtige Richtung). Drittens: `stack-close` beschrieb den eigenen Skill-Schnitt +zu stark („es gibt keinen nächsten Schritt mehr, an dem vorbei zu rutschen wäre"). Wahr für die +*Prozedur*, die nicht mehr im Kontext steht; nicht wahr für den *Auslöser* - `stack-dev`s „invoke +it now" ist weiterhin ein Satz, und die `publish`-Notiz nennt den Skill bewusst nicht beim Namen. +Zwei der drei Kettenglieder bleiben Selbstdisziplin. Der Skill sagt das jetzt selbst, statt sich +als Garantie zu verkaufen, die er nicht ist. + +Verifiziert: `tools/wikitool docs verify`, `tools/wikitool instructions verify`, +`.venv/bin/python -m pytest -q` (969 passed, 2 davon neu), `release.yml` gegen den YAML-Parser +und das Heredoc als Trockenlauf gegen eine Beispiel-Notiz. + +--- + ## 4.6.0 - 2026-09-04 - stack-dev/stack-close skill split, publish stack-machinery note, model-selection fix (#47 Block 2) **Author:** Torben Nehmer diff --git a/VERSION b/VERSION index 6016e8a..8ac28bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.6.0 +4.6.1 diff --git a/instructions/dev/stack-close/SKILL.md b/instructions/dev/stack-close/SKILL.md index 80543d9..3e7b5fc 100644 --- a/instructions/dev/stack-close/SKILL.md +++ b/instructions/dev/stack-close/SKILL.md @@ -32,8 +32,17 @@ the session knew the rule and skipped past it anyway, because nothing in the mom question. Splitting the phase into its own skill does not add a check either - `wikitool` still does not know this tracker exists and must not learn (see [issue-tracking.md](../issue-tracking.md) § What no tool checks) - but it removes the thing that -was actually failing: there is no longer a "next step" to skip past, only a skill nobody has -invoked yet. See Gitea #47 for the full incident history and the rejected alternative (a +was actually failing: the closing *procedure* is no longer sitting in the session's context as a +next step to run past - it exists only inside a skill someone has to invoke. + +**Be precise about what that does and does not buy**, because the honest version is weaker than +"now it cannot be skipped". What did **not** change is the trigger: `stack-dev`'s "invoke it now" +is still a sentence, and `publish`'s stack-machinery note is deliberately generic enough not to +name this skill at all. Two of the three links in that chain remain self-discipline. The split +narrows the failure, it does not close it - treat a session that reaches this text as the +mechanism having worked *this time*, not as proof that it always will. + +See Gitea #47 for the full incident history and the rejected alternative (a model-switched subagent - not buildable in Claude Code, where a fork inherits the parent's model and a fresh subagent starts without the session's context). diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 6c966be..4ea0548 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -58,7 +58,7 @@ tools/wikitool --help | `sources trace --raw ` \| `--page ""` | Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files | | `sources rebuild-index [--dry-run]` | Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing pages) | | `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` | -| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or any `<stage>/CONTRACT.md` - the same scope a stack version bump itself covers - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish | +| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish | | `work new (--input <raw path> \| --key <run key>) [--again] [--dry-run]` | Scaffold `work/<runkey>/` for one workshop run: refuses a collision instead of suffixing it, and writes the required `README.md` + `plan.md`. `--input` derives the run key from the path below `raw/` (an ingest); `--key` names it outright for a run with no raw input - a migration or a sweep across `kb/` - and may not start with `ingest-`, which stays reserved for derived keys. Exactly one of the two. `--again` opens a dated second pass over a tree that has itself changed. See [work/CONTRACT.md](../work/CONTRACT.md) | | `work close --run-key <name> [--yes] [--dry-run]` | Delete a finished workshop. Lists what would be lost and requires `--yes`, because nothing in it is recoverable from the rest of the repo - the durable conclusions must already be in `kb/` | | `budget status` | Show the current session's `wikitool` call count and recent command history (never counted against the budget) | diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index ae7e38e..9f22215 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -136,7 +136,17 @@ ROOT_README = config.ROOT / "README.md" # tools/README.md is exactly the file it drifted in. INSTALL.md is here for the # same reason: it is human-facing prose about installing an instance, and the # command reference lives exactly once, in tools/CONTRACT.md. -STAGE_READMES = ("tools/README.md", "INSTALL.md") +# +# DEVELOPMENT.md joined them after it drifted the same way (Gitea #47): it grew +# a table describing what each verify command checks, which had to be removed by +# hand because nothing compared it to anything. It is not shipped - dist_cmd +# .ROOT_FILES excludes it - and that is not an argument against listing it here: +# `check_readmes_have_no_command_table` skips a file that does not exist, so in +# a distributed instance this entry is simply inert, while in the dev checkout +# (the only place the file exists, and the only place it can drift) it is +# checked. The name is now narrower than the tuple - these are the human-facing +# prose docs that must not re-list commands, stage README or not. +STAGE_READMES = ("tools/README.md", "INSTALL.md", "DEVELOPMENT.md") # Docs that must not re-introduce the pre-migration bare-enum `type:` form. # The per-collection contracts are appended at call time, since which ones exist diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index 33890fd..385283e 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -441,13 +441,18 @@ STACK_MACHINERY_NOTE = ( def touches_stack_machinery(changed_files: list[str]) -> bool: - """Whether `changed_files` includes a path under `version-parts.md`'s - scope for the stack version - `tools/`, `types/`, `instructions/`, - `AGENTS.md`, or any `<stage>/CONTRACT.md`. A publish in this class is, - by construction of the `stack-dev`/`stack-close` split, always followed - by the unchecked closing phase - `STACK_MACHINERY_NOTE` times a reminder - to land exactly there, for any session, not only one that read the - skill that names it.""" + """Whether `changed_files` includes a path under the stack version's own + scope - `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending + in `CONTRACT.md` at any depth. A publish in this class is, by construction + of the `stack-dev`/`stack-close` split, always followed by the unchecked + closing phase - `STACK_MACHINERY_NOTE` times a reminder to land exactly + there, for any session, not only one that read the skill that names it. + + Deliberately a shade broader than CI's version gate, which matches + `<one-segment>/CONTRACT.md` only: this decides whether to print a sentence, + so over-matching costs a reminder nobody needed, while under-matching costs + the reminder in the one case it was built for. The two are not the same + predicate and should not be described as one.""" for path in changed_files: if path in STACK_MACHINERY_NAMES: return True diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index b5f5178..9a20a1f 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -100,6 +100,30 @@ def test_install_md_is_checked_too(tmp_path, monkeypatch): assert any("`doctor`" in issue for issue in issues) +def test_development_md_is_checked_too(tmp_path, monkeypatch): + """DEVELOPMENT.md drifted exactly this way once (Gitea #47): a table + describing what each verify command checks, removed by hand because nothing + compared it to anything.""" + root = tmp_path + (root / "DEVELOPMENT.md").write_text( + "| Command | Purpose |\n| `docs verify` | checks docs |\n", encoding="utf-8" + ) + monkeypatch.setattr(docs_verify.config, "ROOT", root) + monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md") # doesn't exist here + issues = docs_verify.check_readmes_have_no_command_table() + assert any("`docs verify`" in issue for issue in issues) + + +def test_an_absent_listed_doc_is_skipped_not_reported(tmp_path, monkeypatch): + """The distributed-instance case: DEVELOPMENT.md is not shipped, so listing + it must stay inert where the file does not exist rather than failing a tree + that is correct.""" + root = tmp_path + monkeypatch.setattr(docs_verify.config, "ROOT", root) + monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md") + assert docs_verify.check_readmes_have_no_command_table() == [] + + def test_legacy_type_blocks_are_absent(): assert docs_verify.check_legacy_type_blocks() == []