Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6671af6a60 | |||
| b4e450108e | |||
| e00eae08e8 | |||
| fe55ad2a9c | |||
| 24593c5608 | |||
| 3916cb9541 | |||
| 72b2b4424f | |||
| 91bd430ac8 | |||
| d34924d640 | |||
| 87a47cc237 | |||
| d4cbeca5a8 | |||
| 0ba94c63d1 | |||
| 368438e48c | |||
| cd81ba3d4f | |||
| 1b5ffea854 | |||
| d2b1719a4b | |||
| 686c08bb14 | |||
| abe5497cda | |||
| d29d400dd3 | |||
| b1883befc7 | |||
| 56ecfc7fee | |||
| 4e80a07ac7 | |||
| 0b8ca746fa | |||
| 9b461421e8 | |||
| 41f5dfe1cd | |||
| 23307c3c5f | |||
| cfe925a76c | |||
| 23e34a940c |
+12
-6
@@ -120,12 +120,12 @@ jobs:
|
|||||||
# container is no longer a special environment worth a second run.
|
# container is no longer a special environment worth a second run.
|
||||||
# See instructions/dev/testing-conventions.md.
|
# See instructions/dev/testing-conventions.md.
|
||||||
#
|
#
|
||||||
# Coverage is reported, not enforced: there is deliberately no
|
# Coverage is measured and enforced at a floor of 85% against a measured
|
||||||
# `--cov-fail-under` yet (Gitea #10). The threshold gets set in its own
|
# 87.0% - `fail_under` in tools/.coveragerc, not a flag here, so the
|
||||||
# later commit, with the measured number as its justification - one
|
# number sits next to the reasoning that produced it. It was set only
|
||||||
# picked before the number is either too low to bite or too high to
|
# after the number had been watched across 38 runs (Gitea #10, closed).
|
||||||
# survive the next honest commit, and the second kind gets lowered
|
# A red suite from this floor means coverage actually fell; the two
|
||||||
# instead of earned. Config: tools/.coveragerc.
|
# points of headroom already absorb a new thin Typer wrapper.
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
cd tools
|
cd tools
|
||||||
@@ -138,6 +138,12 @@ jobs:
|
|||||||
# v3, not v4 - v4 is restricted on this Gitea instance; v3 is what is
|
# v3, not v4 - v4 is restricted on this Gitea instance; v3 is what is
|
||||||
# proven here (torben/gitea-mcp@ci-build, ci-build.yaml, runs
|
# proven here (torben/gitea-mcp@ci-build, ci-build.yaml, runs
|
||||||
# 42-45).
|
# 42-45).
|
||||||
|
#
|
||||||
|
# The artifact is downloadable from the run page, but the Actions
|
||||||
|
# artifact REST endpoints report `total_count: 0` for it - v3 writes
|
||||||
|
# through the older artifact API, which those endpoints do not read.
|
||||||
|
# An empty list is not a failed upload. See EVALS.md § "How much of the
|
||||||
|
# stack the suite reaches"; do not re-derive this.
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
version="$(cat VERSION | tr -d '[:space:]')"
|
version="$(cat VERSION | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
# A running candidate (`X.Y.Z-beta.N`) is never released - betas are
|
||||||
|
# a dev-checkout state, not a distributed one (see
|
||||||
|
# instructions/dev/version-parts.md). This guard sits *before* the
|
||||||
|
# API query below: without it, every `version bump` on a candidate
|
||||||
|
# would push VERSION and trigger a wasted round-trip against the
|
||||||
|
# releases API for a tag that was never going to be created. Ending
|
||||||
|
# the job cleanly here (not `exit 1`) is what keeps a beta bump a
|
||||||
|
# normal, unremarkable push rather than a failing CI run - skipping
|
||||||
|
# every later step is what "cleanly" means in Actions: mark this one
|
||||||
|
# skip and gate the rest on it.
|
||||||
|
case "$version" in
|
||||||
|
*-beta.*)
|
||||||
|
echo "VERSION is a running candidate (${version}) - nothing to release. Skipping."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
tag="v${version}"
|
tag="v${version}"
|
||||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -82,14 +102,37 @@ jobs:
|
|||||||
- name: Release notes from CHANGES.md
|
- name: Release notes from CHANGES.md
|
||||||
# `version notes` fails when the changelog has no entry for this
|
# `version notes` fails when the changelog has no entry for this
|
||||||
# version, which is the last place that mistake can still be caught.
|
# 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: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
tools/wikitool docs verify
|
tools/wikitool docs verify
|
||||||
tools/wikitool version notes > /tmp/release-notes.md
|
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
|
cat /tmp/release-notes.md
|
||||||
|
|
||||||
- name: Build the distribution tarball
|
- name: Build the distribution tarball
|
||||||
id: build
|
id: build
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
@@ -109,6 +152,7 @@ jobs:
|
|||||||
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Publish the release
|
- name: Publish the release
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
TOKEN: ${{ gitea.token }}
|
TOKEN: ${{ gitea.token }}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
|||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
||||||
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
||||||
|
| `DEVELOPMENT.md` | Humans - the release workflow (`version bump`/`version release`/`publish`/CI), for whoever develops this stack rather than an instance built on it | Never by an agent as instruction. Not shipped: `dist_cmd.ROOT_FILES` excludes it deliberately, the same way `instructions/dev/` (which it may link to, unlike the documents `instructions verify` holds to that rule) is excluded - a distributed instance has no release workflow to document |
|
||||||
| `AGENTS.md` | Agents | Always, every session |
|
| `AGENTS.md` | Agents | Always, every session |
|
||||||
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
||||||
| `USER.md` | Agents | Always, every session |
|
| `USER.md` | Agents | Always, every session |
|
||||||
@@ -82,6 +83,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
|||||||
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
||||||
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
||||||
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
||||||
|
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
|
||||||
| `INDEX.md` | Both | Generated - never hand-edited |
|
| `INDEX.md` | Both | Generated - never hand-edited |
|
||||||
|
|
||||||
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different
|
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different
|
||||||
@@ -89,6 +91,16 @@ documents. What it may not carry is the same content twice - a README that resta
|
|||||||
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
||||||
happened once: no README may hold a copy of the `wikitool` command table.
|
happened once: no README may hold a copy of the `wikitool` command table.
|
||||||
|
|
||||||
|
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
|
||||||
|
background a session consults in passing, not a rule it must follow. Anything that would bind
|
||||||
|
belongs in a `CONTRACT.md` instead, which is what keeps invariant 8 intact here: `docs/` is
|
||||||
|
never a second place a rule could live, only prose about rules that live elsewhere. That is also
|
||||||
|
why nothing verifies its content - there is no rule in it to check. It has no frontmatter, no type, no index, no lint, no decay, no provenance, and no
|
||||||
|
`COLLECTION.md` - which [kb/CONTRACT.md § Collections](kb/CONTRACT.md#collections) forbids
|
||||||
|
outside `kb/` anyway, but the point holds independently: `docs/` stays a plain directory of
|
||||||
|
prose, invisible to everything `tools/wikitool` does except `dist export`, which copies it
|
||||||
|
verbatim. A fresh instance needs the reasoning as much as this one does.
|
||||||
|
|
||||||
## Personalization
|
## Personalization
|
||||||
|
|
||||||
`USER.md` and `SOUL.md` are read at session start, if the runtime has not already injected
|
`USER.md` and `SOUL.md` are read at session start, if the runtime has not already injected
|
||||||
@@ -145,7 +157,8 @@ input schema + compiler output derived (gitignored)
|
|||||||
work/ tracked scratch, deleted when the run closes
|
work/ tracked scratch, deleted when the run closes
|
||||||
```
|
```
|
||||||
|
|
||||||
Alongside it, not part of it: `instructions/` (what agents are told to do) and this file.
|
Alongside it, not part of it: `instructions/` (what agents are told to do), `docs/` (why the
|
||||||
|
stack is built the way it is - see [File naming](#file-naming)), and this file.
|
||||||
|
|
||||||
**By stage** - read the contract for the stage you are writing in:
|
**By stage** - read the contract for the stage you are writing in:
|
||||||
|
|
||||||
@@ -225,7 +238,8 @@ Every `tools/wikitool` call has exactly four outcomes:
|
|||||||
produced.
|
produced.
|
||||||
|
|
||||||
After the single allowed retry - or immediately, for the non-idempotent commands `new`,
|
After the single allowed retry - or immediately, for the non-idempotent commands `new`,
|
||||||
`log append`, and `publish` - stop and report the exact command and error text to the user.
|
`log append`, `publish`, and `upstream merge` - stop and report the exact command and error
|
||||||
|
text to the user.
|
||||||
|
|
||||||
Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is
|
Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is
|
||||||
safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error -
|
safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error -
|
||||||
@@ -263,3 +277,10 @@ and `tools/README.md` are part of the change that introduced a stage, a command
|
|||||||
not follow-up work: nobody comes back for them, and a document that describes a repo which no
|
not follow-up work: nobody comes back for them, and a document that describes a repo which no
|
||||||
longer exists is worse than none. The mechanical half - command tables, contracts, ignore
|
longer exists is worse than none. The mechanical half - command tables, contracts, ignore
|
||||||
canaries - is checked by `tools/wikitool docs verify`; the prose half is yours.
|
canaries - is checked by `tools/wikitool docs verify`; the prose half is yours.
|
||||||
|
|
||||||
|
`docs/` pages are held to a different clock than those three. A README goes stale on every new
|
||||||
|
flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate
|
||||||
|
that stops living in code, an ownership line that moves, a boundary redrawn - which is rarer
|
||||||
|
and not tied to any one commit. Nothing checks this by construction: a page there carries no
|
||||||
|
normative sentence (see [File naming](#file-naming)), so there is no rule for `docs verify` to
|
||||||
|
check, only a rationale for a session to notice has gone stale and to update or retire.
|
||||||
|
|||||||
+986
@@ -18,6 +18,992 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
|
|||||||
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
||||||
their date-only headings.
|
their date-only headings.
|
||||||
|
|
||||||
|
Since `4.4.0` the stack carries **one running candidate** between two
|
||||||
|
releases rather than a fresh version per bump - see
|
||||||
|
`instructions/dev/version-parts.md`. While a candidate is open its heading
|
||||||
|
names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
|
||||||
|
every bump of that same candidate updates this one entry in place rather than
|
||||||
|
opening another: the heading's version/date/title move, and the bump's
|
||||||
|
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||||
|
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
||||||
|
never by hand. `wikitool version release` is what closes a candidate: it
|
||||||
|
strips the suffix and turns the entry into an ordinary, suffix-free one,
|
||||||
|
leaving the bump-title list as the record of what happened. A distributed
|
||||||
|
instance never sees a `-beta.` version at all (`release.yml` only ever
|
||||||
|
releases a fixed one), so this suffix and the list beneath it are a
|
||||||
|
dev-checkout concern - readable here, never shipped as something to parse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.7.3 - 2026-09-04 - eval: gate-not-self-opened prueft REMOVED_FLAGS gegen das eigene Kommando
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Die Trajektorien-Regel `gate-not-self-opened` (`tools/chemenu/evals/trajectory.py`) hat jedes
|
||||||
|
Argument jedes `wikitool.call` gegen `REMOVED_FLAGS = {"--yes": "publish", "-y": "publish"}`
|
||||||
|
geprüft, ohne je das eigene `command`-Feld des Aufrufs gegenzulesen. `--yes`/`-y` sind nur auf
|
||||||
|
`publish` entfernt worden - auf `rm --page <Titel> --yes` sind sie ein gültiger, dokumentierter
|
||||||
|
Flag. Ergebnis: jeder `rm --yes`-Aufruf wurde als Invarianten-Verstoß gemeldet ("an agent
|
||||||
|
inventing a flag the tool never accepts"), obwohl das Tool ihn akzeptiert hatte.
|
||||||
|
|
||||||
|
In den vorhandenen Telemetrie-Traces unter `reports/telemetry/` betraf das 111 `rm`-Aufrufe
|
||||||
|
über 8 Sessions, davon 27 allein in `publish-cleanup/u3` - jede davon fälschlich `FAILED`
|
||||||
|
gescort. Kein bestehender Test hätte das gefangen: `tools/chemenu/tests/test_evals.py` prüfte
|
||||||
|
`REMOVED_FLAGS` ausschließlich über `publish --yes`, nie über ein anderes Kommando.
|
||||||
|
|
||||||
|
Fix: die Bedingung liest jetzt `attrs.get("command") == REMOVED_FLAGS[arg]` mit. Neuer
|
||||||
|
Regressionstest `test_yes_on_a_command_that_still_has_it_is_not_a_finding` deckt genau den
|
||||||
|
`rm --yes`-Fall ab und schlägt gegen den unfixed Code nachweislich fehl.
|
||||||
|
|
||||||
|
Keine Verhaltensänderung an `wikitool` selbst - ausschließlich an der Scoring-Logik unter
|
||||||
|
`tools/chemenu/evals/`.
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- eval: gate-not-self-opened prueft REMOVED_FLAGS gegen das eigene Kommando
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.7.2 - 2026-09-04 - Coverage-Untergrenze bei 85 %, gegen beobachtete 87,0 %
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- Coverage-Untergrenze 85 % in tools/.coveragerc
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Die Suite hat jetzt einen Boden: `fail_under = 85` in `tools/.coveragerc`, gemessen gegen 87,0 %
|
||||||
|
(CI-Lauf 163, 6498 Statements, 975 Tests). Damit ist Gitea #10 geschlossen — das Issue, das die
|
||||||
|
Messung eingerichtet und die Schwelle danach **absichtlich** zurückgehalten hat, bis die Zahl
|
||||||
|
beobachtet war.
|
||||||
|
|
||||||
|
Die Beobachtung ist der eigentliche Inhalt dieses Bumps. Zwischen der ersten Messung (86,9 % von
|
||||||
|
5105 Statements, 730 Tests, Lauf 87, Stack 1.8.1) und heute ist der gemessene Code um ein Viertel
|
||||||
|
gewachsen und die Suite um ein Drittel, über 38 grüne Läufe — und die Quote hat sich um einen
|
||||||
|
Zehntelpunkt bewegt. Eine Untergrenze, die auf dieser Beobachtung steht, ist etwas anderes als
|
||||||
|
eine gegriffene Zahl.
|
||||||
|
|
||||||
|
**85 und nicht 87, und das ist keine Bequemlichkeit.** Der Coverage-Bericht unterscheidet drei
|
||||||
|
Sorten ungedeckter Zeilen, und nur eine davon bedeutet Arbeit (`EVALS.md` § „How much of the
|
||||||
|
stack the suite reaches"). Ein neuer dünner Typer-Wrapper senkt den Gesamtwert, ohne dass
|
||||||
|
irgendetwas schlechter geworden wäre — seine Logik liegt daneben und ist dort getestet. Eine
|
||||||
|
Schwelle auf dem gemessenen Wert würde genau an diesem Commit rot, und eine Schwelle, die aus
|
||||||
|
einem Nicht-Grund rot wird, wird gesenkt statt verdient. Das ist die Fehlerweise, die #10
|
||||||
|
verhindern wollte, nur von der anderen Seite. Die zwei Punkte sind der Platz, den die Taxonomie
|
||||||
|
verlangt.
|
||||||
|
|
||||||
|
`fail_under` steht in der Konfiguration und nicht als `--cov-fail-under` im CI-Schritt: so sitzt
|
||||||
|
die Zahl neben der Begründung, die sie erzeugt hat, und gilt für jeden `--cov`-Lauf statt nur für
|
||||||
|
den einen, den CI schreibt.
|
||||||
|
|
||||||
|
Was der Boden **nicht** tut: die drei echten Lücken schließen (`provenance_cmd.py` 44 %,
|
||||||
|
`migrate_cmd.py` 65 %, `type_resolver.py` 79 %). Er friert den erreichten Stand ein. Diese Liste
|
||||||
|
ist die einzige, die sich nicht bewegt hat, während alles um sie herum wuchs — `migrate_cmd.py`
|
||||||
|
ist sogar von 71 % gefallen, weil das Modul gewachsen ist und die neuen Zeilen ungetestet ankamen.
|
||||||
|
Das ist Gitea #51.
|
||||||
|
|
||||||
|
Mitgenommen, weil es dieselbe Frage beantwortet: der Coverage-Bericht **ist** als Artefakt
|
||||||
|
abrufbar, über die Run-Seite. Die Actions-Artefakt-Endpunkte melden dafür `total_count: 0`, weil
|
||||||
|
`upload-artifact@v3` über die ältere Artifact-API ablegt, die diese Endpunkte nicht lesen. Eine
|
||||||
|
leere Liste ist kein fehlgeschlagener Upload — steht jetzt in `EVALS.md` und im Kommentar an der
|
||||||
|
`Coverage report`-Stufe, damit die naheliegende „Korrektur" auf v4 (hier eingeschränkt) niemandem
|
||||||
|
mehr einfällt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.7.1 - 2026-09-04 - redundant_see_also in tools/CONTRACT.md und wiki-lint dokumentiert; xref-remove-Falle benannt
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- redundant_see_also in tools/CONTRACT.md und wiki-lint; xref-remove-Falle beim Aufräumen benannt
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Die Doku-Hälfte von `4.7.0`, beim Abschluss nachgezogen. Der Befund war ausgeliefert, aber
|
||||||
|
`tools/CONTRACT.md`s `lint`-Zeile zählt die Befunde auf und kannte ihn nicht - eine Instanz hätte
|
||||||
|
eine Sektion im Report gefunden, die ihr Contract nicht erklärt. `docs verify` prüft, dass die
|
||||||
|
Kommandotabelle existiert, nie was in einer Zeile steht; genau die Lücke, für die AGENTS.md
|
||||||
|
„a stack change is not finished until the human docs describe it" geschrieben ist.
|
||||||
|
|
||||||
|
**Die eigentliche Änderung ist aber die Warnung in `wiki-lint`**, und sie ist keine Prosa-Politur.
|
||||||
|
Der neue Befund liest sich wie etwas, das Schritt 7 („repariere, was mechanisch ist") abräumt, und
|
||||||
|
der naheliegende Griff wäre `xref remove` - das die Referenz **beidseitig** löscht. Angewandt auf
|
||||||
|
`Wine see-also Wine GE` neben `Wine GE depends-on Wine` hätte das die schwache *und* die
|
||||||
|
spezifische Kante entfernt, und das Paar sagte danach gar nichts mehr. Ein Befund, dessen
|
||||||
|
offensichtliche Reparatur Daten zerstört, ist schlechter als kein Befund: Schritt 1 nennt die
|
||||||
|
Falle jetzt beim Namen und verweist auf `xref add` (fasst nur die Quellseite an) oder aufs
|
||||||
|
Berichten. Dieselbe Asymmetrie hat in #30 schon einmal Daten gekostet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.7.0 - 2026-09-04 - Link-Katalog: authored, alternative-to, addresses; entity→entity-Lineage; Lint-Befund gegen redundante see-also
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- Link-Katalog: authored, alternative-to, addresses; Lint-Befund gegen redundante see-also
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Drei neue Label, zwei geschlossene Autorisierungslücken und ein Lint-Befund - ausgelöst von der
|
||||||
|
anstehenden ersten produktiven Instanz. Katalog und `COLLECTION.md` gehen über `dist export` in
|
||||||
|
jede neue Instanz; was hier fehlt, fehlt dort ab Tag eins, und nachträglich ist eine
|
||||||
|
Katalogerweiterung eine Korpus-Migration statt einer Datenzeile. Gitea #43 und #49.
|
||||||
|
|
||||||
|
**Die Messung, die den Zuschnitt bestimmt hat.** Die 180 `see-also`-Kanten des Korpus zerfallen
|
||||||
|
exakt in drei Klassen: 57 (32 %) sind Spiegel einer bereits typisierten Gegenkante, 70 (39 %)
|
||||||
|
sind wechselseitige `see-also`-Paare, 53 (29 %) stehen einseitig. Die erste Klasse ist kein
|
||||||
|
Vokabularproblem - `Wine see-also Wine GE` steht neben `Wine GE depends-on Wine`, `RAG see-also
|
||||||
|
NotebookLM` neben `NotebookLM implements RAG`. Der Katalog war für ein Drittel der Fälle längst
|
||||||
|
ausreichend; es hat sie nur nichts gemeldet. Genau dafür ist der Lint-Befund unten da, und er ist
|
||||||
|
der Grund, warum diese Version mehr ist als zwei Katalogzeilen.
|
||||||
|
|
||||||
|
**`authored`** (operationales Register). „hat das Ziel als einmaligen Akt geschaffen." Der Katalog
|
||||||
|
kannte fortlaufende Rechenschaft (`owns`) und fortlaufende Arbeit (`maintains`), aber nicht den
|
||||||
|
historischen Ursprung - Urheberschaft stand im Korpus deshalb in vier unvereinbaren Formen
|
||||||
|
nebeneinander: `source.author` als Freitext, `owns`, `see-also` und ein Prosa-Bullet. Eine davon
|
||||||
|
war sachlich falsch: `Vannevar Bush owns Memex` behauptet laufende Rechenschaft für einen 1974
|
||||||
|
Verstorbenen, und eine falsche maschinenlesbare Kante ist schlechter als eine schwache, weil sie
|
||||||
|
geglaubt wird. Geschrieben wird das Label auf der Entity-Seite (`Andrej Karpathy authored LLM
|
||||||
|
Wiki Pattern`) - die Gegenrichtung `authored-by` auf der Concept-Seite hätte `xref remove`
|
||||||
|
gebraucht, das beidseitig abräumt, statt `xref add`, das relabelt. Vier Kanten im Korpus
|
||||||
|
umgestellt, keine verloren.
|
||||||
|
|
||||||
|
**`alternative-to`** (operationales Register, selbst-dual). „erfüllt denselben Zweck wie das Ziel,
|
||||||
|
so dass ein Leser, der zwischen beiden wählt, beide will." Belegt durch rund 30 Paare, darunter
|
||||||
|
die sieben Agent-CLIs, die untereinander *ausschließlich* `see-also` tragen - keine einzige
|
||||||
|
typisierte Kante. Abgegrenzt gegen `contrasts` (behauptet einen lesenswerten Unterschied) und
|
||||||
|
`compares-with` (wiegt auf benannten Dimensionen ab und führt in dieser Instanz auf eine
|
||||||
|
`kb/comparisons/`-Seite): zwei Agent-CLIs sind austauschbar, zwei gegensätzliche Entwurfsprinzipien
|
||||||
|
sind es nicht.
|
||||||
|
|
||||||
|
Der Katalog sagt jetzt ausdrücklich, dass ein selbst-duales Label **einmal pro Paar** geschrieben
|
||||||
|
wird. Ohne diesen Satz wäre aus einer 22-Kanten-`see-also`-Clique eine 22-Kanten-
|
||||||
|
`alternative-to`-Clique geworden und nichts gewonnen: sieben austauschbare Werkzeuge sind 21
|
||||||
|
Paare, beidseitig deklariert 42 Kanten, von denen die zweiten 21 nichts sagen.
|
||||||
|
|
||||||
|
**`addresses`** (konzeptionelles Register). „ist eine Antwort auf das Problem, das das Ziel
|
||||||
|
beschreibt." `types/concept.md` deklariert `problem` und `decision` als eigene Subtypen, und der
|
||||||
|
Katalog hatte kein Label, das eine Entscheidung mit dem Problem verbindet, das sie löst - eine
|
||||||
|
Collection konnte ein Problem benennen und nie sagen, was dagegen unternommen wurde. Abgegrenzt
|
||||||
|
gegen `rests-on`, das das Ziel als *Prämisse* nimmt statt als zu lösendes Problem. Im Korpus nur
|
||||||
|
vier belegte Paare, also dünn nach dem sonst geltenden „erst der Anwendungsfall"-Maßstab; die
|
||||||
|
Ausnahme ist bewusst und gilt dem Auslieferungszeitpunkt, der die Kosten umdreht.
|
||||||
|
|
||||||
|
**Zwei Autorisierungslücken entity→entity.** `kb/entities/COLLECTION.md` erlaubte bisher keine
|
||||||
|
Lineage zwischen zwei Entities - ein Fork, eine Neuimplementierung, ein Nachbau war nicht
|
||||||
|
ausdrückbar; `derived-from` und `adapted-from` sind jetzt freigegeben. Ebenso `implements`, für
|
||||||
|
eine Entity, die eine als Entity geführte Konvention umsetzt. Ein autorisiertes Label ohne
|
||||||
|
Live-Nutzung ist ausdrücklich in Ordnung (`instructions/dev/corpus-policy.md`).
|
||||||
|
|
||||||
|
**Lint-Befund `redundant_see_also`.** Meldet eine `see-also`-Kante, deren Gegenrichtung bereits
|
||||||
|
ein typisiertes Label trägt. Gegen den Korpus dieser Instanz meldet er genau die gemessenen 57.
|
||||||
|
**Advisory, nicht hart**, aus zwei Gründen zugleich: eine schwache Kante neben einer spezifischen
|
||||||
|
ist redundant, nicht kaputt - und der Befund kommt lange nach den Korpora, die er beurteilt, also
|
||||||
|
würde eine harte Einstufung jede bestehende Instanz mit dem Upgrade rot schalten, das ihn
|
||||||
|
ausliefert. Anders als `unlabelled_edges` ist er auch nicht migrations-gegatet: es gibt keine
|
||||||
|
Version, ab der die Redundanz zum Fehler wird, nur einen Sweep, zu dem jemand kommt oder nicht.
|
||||||
|
|
||||||
|
`links.SEE_ALSO` ist damit das einzige Katalog-Label, das das Werkzeug beim Namen kennt. Das ist
|
||||||
|
eine begründete Ausnahme, keine Aufweichung: `see-also` ist der erklärte letzte Ausweg des
|
||||||
|
Katalogs und behauptet nur, dass nichts Besseres passte - was der einzige Grund ist, warum `lint`
|
||||||
|
eine Kante als *schwächer als* eine andere über demselben Paar beurteilen kann. Alles andere am
|
||||||
|
Vokabular bleibt in `instructions/link-taxonomy.md` und den `outbound:`-Blöcken.
|
||||||
|
|
||||||
|
**Nicht dabei, bewusst.** Der Sweep der 180 bestehenden Kanten (#48) - diese Version ändert außer
|
||||||
|
den vier Urheberschaftskanten keine Korpus-Kante. Verworfen wurden außerdem `variant-of` (die
|
||||||
|
Wine-Forks tragen bereits `depends-on Wine`), `implemented-by` (Spiegel von `implements`, den die
|
||||||
|
Inbound-View rendert), `sibling-of` für die Concept-Cliquen (Über-Verlinkung, kein fehlendes Wort)
|
||||||
|
und `builds-on` (Vokabularkollision mit `extends`/`derived-from`/`adapted-from`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.6.1 - 2026-09-04 - DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3)
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- stack-dev/stack-close skill split, publish stack-machinery note, model-selection fix (#47 Block 2)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Block 2 aus #47 (Vorschlag E, am 2026-09-04 entschieden): die ungeprüfte Schlussphase einer
|
||||||
|
Stack-Sitzung - Issue-Body-Rewrite, `docs/`-Veralterung, Changelog-Prosa - hatte bisher keinen
|
||||||
|
eigenen Haltepunkt, sondern einen Prosa-Break in `stack-dev` Schritt 6. Der ist zweimal
|
||||||
|
hintereinander verschluckt worden (#42, #30), beide Male mit echtem Fund im nachgeholten
|
||||||
|
Durchgang. Ein dritter Prosa-Haltepunkt hätte dieselbe Wette verloren, die
|
||||||
|
`docs/why-gates-are-code.md` für Gates schon verliert - also keine Prosa-Lösung mehr, sondern ein
|
||||||
|
struktureller Schnitt.
|
||||||
|
|
||||||
|
**Neuer Skill `stack-close`**, dev-only wie `stack-dev`. `stack-dev` endet nach `tools/wikitool
|
||||||
|
publish` mit einem Stop statt mit einem sechsten Schritt; die Schlussphase existiert nur noch als
|
||||||
|
eigener Skill, den eine Sitzung aufrufen muss - es gibt keinen „nächsten Schritt" mehr, an dem
|
||||||
|
vorbei sie rutschen könnte. `stack-close` trägt drei Dinge: den Modell-Rückwechsel-Hinweis (wie
|
||||||
|
zuvor), die Body-Rewrite-Disziplin aus `issue-tracking.md` Schritte 2-3 und 7, und neu die
|
||||||
|
**Handover-Pflicht über die ganze Sitzung**: benannt wird das Modell für Design/Versionsstelle
|
||||||
|
(Schritt 3), für die mechanische Mitte, und für diese Schlussphase - alle drei, auch wenn sie
|
||||||
|
identisch sind. Eine Handover-Zeile, die nur eine billige Schlussphase meldet, schweigt genau
|
||||||
|
dann, wenn die ebenso ungeprüfte Design-Phase auch billig lief und niemand dort gewechselt hat.
|
||||||
|
|
||||||
|
Ein Agenten-Zuschnitt (Schlussphase als eigener Subagent mit eigenem Modell) wurde geprüft und
|
||||||
|
verworfen: ein Fork erbt in Claude Code zwingend das Elternmodell, ein frischer Subagent den
|
||||||
|
Sitzungskontext nicht - die Kombination, die der Zuschnitt bräuchte, gibt es nicht, und selbst
|
||||||
|
wenn: der Input der Schlussphase *ist* das akkumulierte Sitzungswissen, das ein kalter Agent aus
|
||||||
|
Diff und Issue neu ableiten müsste. Volle Begründung im Body von #47.
|
||||||
|
|
||||||
|
**`instructions/claude-code-model-selection.md` korrigiert**, im dist-strip-Block: die
|
||||||
|
Übersicht „stack-dev Schritt 3 und 6" ist falsch geworden, seit Schritt 6 nicht mehr existiert.
|
||||||
|
Sie benennt jetzt beide Haltepunkte an ihrem tatsächlichen Ort - Schritt 3 in `stack-dev`,
|
||||||
|
der zweite am Anfang von `stack-close`.
|
||||||
|
|
||||||
|
**`stack-dev` Schritt 3 ehrlicher formuliert** (Vorschlag C): nicht mehr „ab hier alles
|
||||||
|
mechanisch", sondern mit benannter Ausnahme - Changelog-Prosa (Schritt 4), eine berührte
|
||||||
|
`docs/`-Seite, neue Menschendoku, der Prosa-Anteil einer Instruction. Dazu die Einschränkung aus
|
||||||
|
#30: „durch Tests abgedeckt" gilt nur für das, was die Tests *treffen* - zwei
|
||||||
|
datenvernichtende Bugs in `upstream merge` liefen an einem grünen `pytest`/`docs
|
||||||
|
verify`/`instructions verify`/CI vorbei, weil kein Test den Fall traf, nicht weil ein
|
||||||
|
schwächeres Modell schlechteren Code für den getesteten Fall geschrieben hätte.
|
||||||
|
|
||||||
|
**Neu: `tools/wikitool publish` selbst erinnert an die Phasengrenze.** Berührt das Changeset
|
||||||
|
`tools/`, `types/`, `instructions/`, `AGENTS.md` oder ein `<stage>/CONTRACT.md` - derselbe
|
||||||
|
Umfang, den ein Versions-Bump selbst abdeckt -, druckt `publish` nach der Erfolgsmeldung eine
|
||||||
|
Zeile, dass die folgende Phase von keinem der drei Checks abgedeckt ist. Kein Gate, keine
|
||||||
|
Änderung am Exit-Code, für eine gewöhnliche Content-Publish stumm; harness- und
|
||||||
|
instanzneutral formuliert, ohne jede Erwähnung eines Trackers, weil `publish` von jedem
|
||||||
|
Skill genutzt wird, nicht nur von `stack-dev`. `git_publish.touches_stack_machinery()` plus
|
||||||
|
vier neue Tests (`test_git_publish.py`): zwei für die reine Klassifikationsfunktion
|
||||||
|
(positiv/negativ), zwei Integrationstests gegen einen echten Publish - die Notiz erscheint genau
|
||||||
|
einmal bei einer `instructions/`-Änderung und bleibt aus bei einer gewöhnlichen `kb/`-Änderung.
|
||||||
|
`tools/CONTRACT.md`s `publish`-Zeile trägt die Kurzfassung, absichtlich ohne den Dateinamen
|
||||||
|
`version-parts.md` zu nennen - die Datei liegt unter `instructions/dev/` und würde in einer
|
||||||
|
ausgelieferten Instanz ins Leere zeigen, während `tools/CONTRACT.md` selbst ausgeliefert wird.
|
||||||
|
|
||||||
|
Verifiziert: `tools/wikitool instructions sync` (7 Skills, `stack-close` neu), `tools/wikitool
|
||||||
|
docs verify`, `tools/wikitool instructions verify`, `.venv/bin/python -m pytest -q` (967
|
||||||
|
passed, 4 davon neu).
|
||||||
|
|
||||||
|
#47 bleibt offen für Block 3 (`DEVELOPMENT.md` in `STAGE_READMES`, veraltete Release-Notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.5.1 - 2026-09-04 - issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Block 1 aus #47 (gemeinsam mit #29): drei Ergänzungen an
|
||||||
|
`instructions/dev/issue-tracking.md`, ausgelöst durch zwei Fehlerklassen, die
|
||||||
|
in derselben Sitzung am Stack aufgetreten waren.
|
||||||
|
|
||||||
|
- **Schritt 1** trägt jetzt, dass destruktive Schritte im Body die Invariante
|
||||||
|
nennen müssen, die sie nicht verletzen dürfen, und dass ein
|
||||||
|
Akzeptanzkriterium eine prüfbare Eigenschaft ist, keine Tätigkeit. Auslöser
|
||||||
|
war #30: der Body schrieb wörtlich "Arbeitsverzeichnis entfernen" für den
|
||||||
|
`upstream merge`-Ablauf, und genau das wurde zum datenvernichtenden Bug -
|
||||||
|
ein `shutil.rmtree` auf eine Stage mit gitignorierten, nicht
|
||||||
|
rekonstruierbaren Daten.
|
||||||
|
- **Schritt 2** trägt jetzt die Lesesicht auf Body und Kommentare, die es
|
||||||
|
bisher nur aus Autorensicht gab: der Body ist der Stand, Kommentare sind
|
||||||
|
Historie; ein erkennbar veralteter Body wird richtiggestellt statt
|
||||||
|
umgangen; widersprüchliche Kommentare werden nach Beleg aufgelöst, nicht
|
||||||
|
nach Datum. Auslöser war ebenfalls #30 (ein Kommentar empfahl das Gegenteil
|
||||||
|
dessen, was der Body später festlegte) und #10 (ein seit Tagen veralteter
|
||||||
|
Body gegen drei widersprechende Kommentare, zwei davon sich selbst
|
||||||
|
widersprechend).
|
||||||
|
- Neuer Abschnitt **"Renames and other decay in the tracker"** nach Schritt 7:
|
||||||
|
ein Rename ist erst fertig, wenn auch die offenen Issues nachgezogen sind,
|
||||||
|
weil `wikitool` diesen Tracker nicht kennt und nicht kennenlernen soll. Mit
|
||||||
|
der Wegweiser-vs-Beleg-Unterscheidung aus #29 und dem Hinweis, dass auch
|
||||||
|
verschwundene `kb/`-Seiten und private Infrastrukturangaben Issue-Texte
|
||||||
|
altern lassen. Ein neuer Trigger in "When to run" verweist darauf.
|
||||||
|
|
||||||
|
Keine der drei Ergänzungen verschiebt die bestehende Nummerierung der
|
||||||
|
Schritte 1-7 - die Querverweise darauf (u. a. aus
|
||||||
|
`instructions/dev/stack-dev/SKILL.md` auf Schritt 7, aus
|
||||||
|
`kb/concepts/Issue Label Scheme.md` auf Schritt 2) bleiben also gültig, ohne
|
||||||
|
angefasst zu werden.
|
||||||
|
|
||||||
|
Verifiziert: `tools/wikitool instructions verify`, `tools/wikitool docs
|
||||||
|
verify`, beide grün (Prosa-only, kein Interface geändert, PATCH).
|
||||||
|
|
||||||
|
#47 bleibt offen (Block 2: der Skill-Schnitt aus Vorschlag E; Block 3: die
|
||||||
|
beiden Nebenbefunde). #29 bleibt ebenfalls offen: dieser Block deckte nur den
|
||||||
|
Regelabsatz, nicht den noch ausstehenden Pfad-Durchgang durch #4, #5, #15,
|
||||||
|
#21, #16, #26 - der war nicht Teil des Auftrags für diesen Block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.5.0 - 2026-09-04 - Beide Update-Wege in Code: upstream merge fuer Clones, dist upgrade fuer Tarball-Instanzen
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- Faktenkorrekturen in der 4.4.0-Prosa; DEVELOPMENT.md ohne zweite Kommandobeschreibung
|
||||||
|
- wikitool upstream merge/verify: code procedure for taking a stack update, ownership.py as the shared stack/instance boundary
|
||||||
|
- upstream merge: combined-commit regression test (edit+add+delete+contract+template+contract-delete in one commit)
|
||||||
|
- upstream merge: keep gitignored local data under a content stage, refuse a merge git never opened, report what actually changed
|
||||||
|
- dist upgrade: apply a stack update, not just detect one (#7)
|
||||||
|
- dist upgrade: Stamp-Semantik nach --keep-local benannt, docs/ownership-and-templates.md auf drei Eigentumsklassen nachgezogen
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Die Prosa zu 4.4.0 - Changelog-Eintrag, `docs/version-model.md`, `instructions/dev/version-parts.md`,
|
||||||
|
`DEVELOPMENT.md` - entstand ungeprüft: kein maschineller Check liest sie, und die Sitzung, die sie
|
||||||
|
schrieb, hat den dafür vorgesehenen Modellwechsel aus `stack-dev` Schritt 6 übersprungen. Ein
|
||||||
|
nachgeholter Durchgang hat drei Fehler gefunden, von denen der erste der teuerste war.
|
||||||
|
|
||||||
|
**Der Befund war an einer Stelle schlicht falsch.** Changelog und `docs/version-model.md`
|
||||||
|
behaupteten, unter dem alten Modell seien Nummern vergeben worden, die „nie ausgeliefert" wurden -
|
||||||
|
im Changelog sogar mit einer erfundenen Zahl („fünf Minor-Bumps ... vier nie ausgeliefert"). Das
|
||||||
|
Gegenteil stimmt: weil `release.yml` auf jede `VERSION`-Bewegung feuerte, wurde **jeder** Bump zu
|
||||||
|
einem echten, getaggten Release. Der 2026-09-03 hat vier davon in sechs Stunden erzeugt (`4.3.0`
|
||||||
|
bis `4.3.3`), zwei für reine Prosa-Änderungen. Der Schaden war nie ein Phantom-Release, sondern
|
||||||
|
dass „Release" aufhörte, etwas zu bedeuten - vier Upgrades an einem Nachmittag sind für einen
|
||||||
|
Konsumenten kein kleineres Versprechen, sondern ein unleserliches. Beide Stellen tragen jetzt den
|
||||||
|
tatsächlichen Vorgang samt Beleg.
|
||||||
|
|
||||||
|
Dazu zwei kleinere Korrekturen: `version-parts.md` nannte den Stack weiterhin `2.x` (er steht bei
|
||||||
|
`4.x`), und `DEVELOPMENT.md` trug eine Tabelle, die für jeden Verify-Befehl ein zweites Mal
|
||||||
|
beschrieb, was er prüft - eine Kopie dessen, was `tools/CONTRACT.md` hält und `docs verify` dort
|
||||||
|
gegen die CLI prüft. Die Tabelle ist raus; dass die Datei selbst außerhalb der von diesem Check
|
||||||
|
abgedeckten Dokumente liegt, steht jetzt an ihrer Stelle. Der Code-seitige Teil davon - ob
|
||||||
|
`DEVELOPMENT.md` in `docs_verify.STAGE_READMES` gehört - hängt an #47, zusammen mit der Lücke im
|
||||||
|
Skill, die den übersprungenen Modellwechsel überhaupt erst unauffällig gemacht hat.
|
||||||
|
|
||||||
|
Kein Verhalten geändert, nur Prosa - und bewusst als laufender Kandidat gelassen statt fixiert:
|
||||||
|
CIs Version-Gate verlangt die `VERSION`-Bewegung, ein Release verlangt sie nicht. Genau dafür gibt
|
||||||
|
es das Modell aus 4.4.0.
|
||||||
|
|
||||||
|
**Zweiter Bump auf demselben Kandidaten (#30):** `git merge upstream/main` behandelt einen
|
||||||
|
bewegten Korpus asymmetrisch - eine gelöschte, upstream-geänderte Seite meldet sich als Konflikt,
|
||||||
|
eine neu angelegte Seite wird still gestaged, nur eine beidseitig gelöschte Seite ist harmlos. Die
|
||||||
|
Prosa-Prozedur in `private-instance.md` § "Taking a stack update" hat das geschlossen, aber mit
|
||||||
|
vier eigenen Fehlern: der Pfadsatz stand dreifach (im Skript, im Kontroll-Grep, implizit in
|
||||||
|
`dist_cmd.py`); eine vom Upstream **gelöschte** Maschinerie-Datei wurde von `git checkout
|
||||||
|
MERGE_HEAD -- <pfad>` still ignoriert, weil das Skript kein `set -e` hatte; ein echter Konflikt in
|
||||||
|
`tools/`/`types/`/`instructions/` endete in einem von der Prosa nirgends erwähnten offenen Merge;
|
||||||
|
und ein *neuer* Maschinerie-Pfad unter einer Content-Stage hätte die Literal-Liste nie erreicht.
|
||||||
|
|
||||||
|
Die Eigentumsgrenze ist jetzt ein Prädikat statt einer Liste: `chemenu/ownership.py`,
|
||||||
|
`is_stack_owned(relative)`, wahr für `<stage>/CONTRACT.md` direkt unter einer Content-Stage
|
||||||
|
(`kb`, `raw`, `work`, `reports`) und für jeden Pfad, der dort auf `.template` endet - nicht
|
||||||
|
rekursiv, `kb/<collection>/COLLECTION.md` bleibt seit #39 instanzeigen. `dist_cmd.py` liest das
|
||||||
|
Modul jetzt statt einer eigenen `_CONTENT_ALLOWED_NAMES`-Liste zu pflegen, und
|
||||||
|
`CONTRACT_ONLY_STAGES` leitet sich aus `ownership.CONTENT_STAGES` ab statt die drei Stage-Pfade
|
||||||
|
ein zweites Mal aufzuschreiben - ein Test hält fest, dass beide Sichten nicht auseinanderlaufen
|
||||||
|
können.
|
||||||
|
|
||||||
|
Neu: `wikitool upstream merge [--remote upstream] [--branch main] [--no-fetch]` und `wikitool
|
||||||
|
upstream verify --since <rev> [--until HEAD]`. `merge` prüft Vorbedingungen (sauberer Baum, kein
|
||||||
|
laufendes Merge, Remote löst auf), warnt statt zu blockieren, wenn `.wikitool-remotes.json` fehlt,
|
||||||
|
hält den Merge offen (`--no-commit --no-ff`), zwingt jede Content-Stage auf die lokale Seite
|
||||||
|
zurück, holt dann über die Vereinigungsmenge der Bäume von `MERGE_HEAD` und `HEAD` genau die
|
||||||
|
stack-eigenen Pfade zurück - inklusive einer Löschung, falls der Upstream einen Maschinerie-Pfad
|
||||||
|
entfernt hat -, verweigert bei verbliebenen unaufgelösten Pfaden ohne zu committen, committet
|
||||||
|
sonst und verifiziert den entstandenen Commit mit derselben Logik wie `verify` - ein Fund dort
|
||||||
|
wird laut gemeldet und **nicht** automatisch zurückgerollt. Nicht idempotent (AGENTS.md § Tool
|
||||||
|
error contract), nicht budget-exempt; `verify` liest nur und ist wie `migrate verify` von der
|
||||||
|
Budget-Gate ausgenommen. Die Mass-Update-Gate greift bei einem Merge-Commit strukturell nicht -
|
||||||
|
das steht jetzt als eigener Absatz in `instructions/gates.md`, mit `upstream merge`s eigener
|
||||||
|
Nachkontrolle als der Sicherung, die hier tatsächlich trägt.
|
||||||
|
|
||||||
|
`private-instance.md` § "Taking a stack update" verweist jetzt auf den Befehl statt das Skript
|
||||||
|
auszuschreiben; die Pfadtabelle bleibt als Erklärung stehen, ist aber nicht mehr die operative
|
||||||
|
Liste. Vorschlag B (eigenes Demo-Repo) bleibt zurückgestellt, siehe #30.
|
||||||
|
|
||||||
|
23 neue Tests unter `test_upstream_cmd.py` (35 Fälle mit der parametrisierten
|
||||||
|
`is_stack_owned`-Tabelle) decken die Fälle aus der Spezifikation ab:
|
||||||
|
gelöschte vs. geänderte vs. neu angelegte Seiten, `kb/CONTRACT.md`- und Template-Änderungen,
|
||||||
|
`kb/entities/COLLECTION.md` bleibt lokal, eine gelöschte `raw/CONTRACT.md` landet, ein neuer
|
||||||
|
Template-Pfad landet, ein offener `work/`-Lauf landet nicht, ein echter `tools/`-Konflikt lässt
|
||||||
|
den Merge offen, ein schmutziger Baum wird unberührt abgewiesen, "bereits aktuell" ist ein No-op,
|
||||||
|
die Publish-Remote-Gate-Warnung, `verify` gegen einen von Hand verpfuschten Merge, und die
|
||||||
|
`dist_cmd`/`ownership`-Konsistenz.
|
||||||
|
|
||||||
|
**Dritter Bump auf demselben Kandidaten:** die im Akzeptanzkriterium geforderte Kombinationsprobe
|
||||||
|
fehlte noch - ein einzelner Upstream-Commit, der Editieren, Anlegen, Löschen einer Seite, eine
|
||||||
|
Contract-Änderung, eine Template-Änderung und eine Contract-Löschung gleichzeitig bewegt. Jetzt
|
||||||
|
als `test_one_upstream_commit_mixing_every_case_at_once` nachgetragen; alle sechs Erwartungen in
|
||||||
|
einem `upstream merge`-Aufruf verifiziert.
|
||||||
|
|
||||||
|
**Vierter Bump: zwei Fehler, die ein Review-Durchgang nach dem Publish gefunden hat.** Beide
|
||||||
|
waren in der ersten Fassung enthalten, beide hätten Daten vernichtet, und keiner der bestehenden
|
||||||
|
Tests hat sie berührt.
|
||||||
|
|
||||||
|
*Erstens: die Content-Stage wurde als Ganzes gelöscht.* `_restore_stage_to_local` hieß in der
|
||||||
|
ersten Fassung `shutil.rmtree(stage_dir)` — die wörtliche Übersetzung des `rm -rf kb raw` aus der
|
||||||
|
Prosa-Prozedur. Für `kb/` und `raw/` ist das harmlos, weil dort nichts Ignoriertes liegt. Für die
|
||||||
|
beiden Stages, die dieses Issue *neu* in den Satz aufgenommen hat, ist es das nicht: `reports/`
|
||||||
|
ist bis auf seinen Contract komplett gitignored und trägt genau die Daten, die nirgends sonst
|
||||||
|
existieren — die Telemetrie-Traces, aus denen `eval score` liest, gespeicherte Eval-Berichte,
|
||||||
|
alte Lint-Reports. In dieser Instanz standen zum Zeitpunkt des Fundes 497 Trace-Verzeichnisse
|
||||||
|
unter `reports/telemetry/`; ein einziger `upstream merge` hätte sie alle gelöscht, und zwar
|
||||||
|
stillschweigend, weil git von ignorierten Dateien nichts meldet. Die Stage wird jetzt über die
|
||||||
|
**getrackten** Pfade beider Bäume zurückgesetzt statt über das Verzeichnis; ignorierte lokale
|
||||||
|
Daten bleiben unberührt. Leergewordene Verzeichnisse werden aufgeräumt, aber nur wirklich leere.
|
||||||
|
|
||||||
|
*Zweitens: ein Merge, den git nie eröffnet hat, hätte die Maschinerie gelöscht.* Der Exit-Code
|
||||||
|
von `git merge --no-commit --no-ff` wird bewusst ignoriert (Konflikte unter den Content-Stages
|
||||||
|
sind erwartet). Nur: wenn git das Merge gar nicht erst eröffnet — unverwandte Historien, eine
|
||||||
|
ignorierte Datei im Weg —, gibt es kein `MERGE_HEAD`, `_tree_paths("MERGE_HEAD")` liefert die
|
||||||
|
leere Menge, und **jeder** stack-eigene Pfad in `HEAD` fällt damit in den Zweig „der Upstream hat
|
||||||
|
ihn gelöscht": `kb/CONTRACT.md`, `raw/CONTRACT.md` und sämtliche Templates werden entfernt. Der
|
||||||
|
Kommando-Ablauf prüft jetzt nach dem Merge-Aufruf, dass tatsächlich ein Merge offen ist, und
|
||||||
|
bricht sonst ab, ohne den Baum angefasst zu haben. Beide Fehler haben je einen Regressionstest,
|
||||||
|
und beide Tests wurden gegen die alte Fassung laufen gelassen, um zu zeigen, dass sie sie
|
||||||
|
tatsächlich fangen.
|
||||||
|
|
||||||
|
Dazu eine Ehrlichkeitskorrektur an der Erfolgsmeldung: sie zählte die *wiederhergestellten*
|
||||||
|
Pfade, nicht die geänderten — ein Merge, der eine Datei bewegt, meldete vier oder fünf. Sie fragt
|
||||||
|
jetzt `git diff` zwischen Vor- und Nach-Commit, kennzeichnet Löschungen, und stimmt damit mit dem
|
||||||
|
überein, was ein Leser nachprüfen würde. `docs/ownership-and-templates.md` hat einen Abschnitt
|
||||||
|
bekommen, warum die Grenze ein Prädikat und keine Liste ist — die Begründung, die dieses Issue
|
||||||
|
erarbeitet hat, gehörte in die Hintergrunddoku und nicht nur in einen Changelog-Eintrag.
|
||||||
|
|
||||||
|
**Fünfter Bump: `wikitool dist upgrade` (#7), der zweite der beiden Update-Wege.** `upstream
|
||||||
|
merge` oben bedient eine Instanz mit gemeinsamer Git-History; `dist upgrade` bedient eine
|
||||||
|
Instanz aus einem Tarball, ohne History, die bislang eine rein manuelle Prozedur in
|
||||||
|
`INSTALL.md` durchlaufen musste - Schritt 4 verlangte einen sha256-Vergleich von Hand gegen den
|
||||||
|
`files`-Block der alten `.wikitool-release.json`.
|
||||||
|
|
||||||
|
Die tragende Regel: die Schreibmenge ist genau der `files`-Block der *neuen*
|
||||||
|
`.wikitool-release.json`, minus was ein Export aus einer leeren Vorlage neu sät
|
||||||
|
(`chemenu.ownership.is_export_stub`, wie bisher schon für `kb/log.md`/`.gitkeep`) oder einmalig
|
||||||
|
sät und danach der Instanz gehört (`chemenu.ownership.is_upgrade_preserved`, neu für
|
||||||
|
`.wikitool-kb.json` und `CHANGES.md`), plus der Stamp selbst. Jeder Kandidatpfad wird gegen die
|
||||||
|
*alte* Instanz-Summe klassifiziert: unverändert wird geräuschlos überschrieben, neu im Release
|
||||||
|
wird angelegt, lokal verändert oder gelöscht wird **nie** still überschrieben - der Lauf bricht
|
||||||
|
mit der vollständigen Liste ab, außer `--keep-local` sagt ausdrücklich, dass die Dateien liegen
|
||||||
|
bleiben sollen. `--prune` entfernt zusätzlich aus dem Release entfallene Dateien, aber nur
|
||||||
|
solche, die seit der Installation unverändert sind.
|
||||||
|
|
||||||
|
Die Migrationskette nach dem Tausch wird aus den `instructions/migrations/` des *neuen* Baums
|
||||||
|
ermittelt (`kb_state.load_migrations` bekam dafür einen `directory`-Parameter) und nur
|
||||||
|
gemeldet, nie ausgeführt - es gibt bewusst kein `migrate run`. Eine bereits gegen die
|
||||||
|
*installierte* Maschinerie offene Kette lässt den Befehl abbrechen, bevor er die Quelle
|
||||||
|
überhaupt öffnet. `kb_state.divergent_files()` (bisher nur von `migrate status` gelesen) ist
|
||||||
|
jetzt eine dünne Hülle um das neue, zwei-Baum-fähige `compare_against_stamp()` - gleiches
|
||||||
|
Verhalten für den bestehenden Aufrufer, wiederverwendbar für `dist upgrade`s eigenen Vergleich.
|
||||||
|
|
||||||
|
Quelle ist immer ein bereits vorhandenes Verzeichnis oder `.tar.gz` - kein Download, das bleibt
|
||||||
|
allein `version check`s Sache. Ein Tarball muss genau ein Top-Level-Verzeichnis enthalten (die
|
||||||
|
Form, in der `release.yml` es baut) und wird gegen eine `.sha256`-Beidatei geprüft, falls eine
|
||||||
|
danebenliegt (fehlt sie: WARN, kein Abbruch). Weitere Abbruchgründe vor jedem Schreiben: fehlende
|
||||||
|
lokale `VERSION`/`.wikitool-kb.json`/Stamp mit `files`-Block, ein schmutziger Arbeitsbaum (kein
|
||||||
|
Git-Repo ist ein WARN, keine Sperre), eine Vorab-Version (`-beta.N`) ohne `--pre`, sowie ein
|
||||||
|
Downgrade; Gleichstand ist ein No-op. Ein Grenzübertritt der Kompatibilität wird laut gemeldet,
|
||||||
|
blockiert aber nicht. Committet und pusht nichts (Invariante 5).
|
||||||
|
|
||||||
|
Gegenüber dem ersten Entwurf des Issues zwei Korrekturen, die dort auch nachgetragen sind: der
|
||||||
|
`files`-Block wurde entgegen der ursprünglichen Annahme bereits vor diesem Bump gelesen
|
||||||
|
(`divergent_files`/`migrate status`), und die Migrationskette war ursprünglich falsch begründet
|
||||||
|
- sie kann nur aus dem *neuen* Baum kommen, nicht durch eine andere Abfragereihenfolge aus der
|
||||||
|
alten Instanz. 24 neue Tests unter `test_dist_upgrade.py` decken die Klassifikation, alle
|
||||||
|
Abbruchgründe, `--keep-local`, `--prune` und beide Quellformen (Verzeichnis und Tarball,
|
||||||
|
inklusive der sha256- und Top-Level-Prüfung) ab.
|
||||||
|
|
||||||
|
Bewusst nicht angetastet: `instructions/private-instance.md` (der Clone-Weg ändert sich nicht,
|
||||||
|
`INSTALL.md` benennt jetzt beide Wege nebeneinander) und die Frage, wie `dist upgrade` mit
|
||||||
|
Collection-Templates umgeht, deren Namen eine fremde Instanz gar nicht hat - es verhält sich wie
|
||||||
|
`upstream merge` und schreibt sie, was ein eigenes Issue gegen den Export wäre, keins gegen das
|
||||||
|
Upgrade.
|
||||||
|
|
||||||
|
**Sechster Bump: die ungeprüfte Phase nachgeholt.** Der Abschluss des vorigen Bumps lief auf
|
||||||
|
Sonnet, und `stack-dev` Schritt 6 verlangt dort genau zwei Dinge, die kein Check erzwingt:
|
||||||
|
Issue-Body und `docs/`-Veralterung. Der Body war gemacht, die `docs/`-Prüfung nicht - sie wurde
|
||||||
|
benannt statt durchgeführt. Nachgeholt auf Opus, mit einem Fund.
|
||||||
|
|
||||||
|
`docs/ownership-and-templates.md` § „The consequence in practice" beschrieb ein Upgrade als
|
||||||
|
Zweiteilung: verbatim überschreiben, `.template`-gestützte Dateien liegen lassen - und begründete
|
||||||
|
den ersten Teil damit, dass verbatim ausgelieferte Dateien „safe to replace wholesale" seien,
|
||||||
|
weil sie „never instance-specific to begin with" waren. Genau diese Annahme trifft `dist upgrade`
|
||||||
|
nicht: eine Instanz *kann* eine verbatim ausgelieferte Datei angefasst haben, und die sha256 je
|
||||||
|
Datei existiert, um das zu erkennen, statt es vorauszusetzen. Dazu fehlte die dritte Klasse ganz -
|
||||||
|
die einmalig gesäten, danach instanzeigenen Pfade (`.wikitool-kb.json`, `CHANGES.md`,
|
||||||
|
`kb/log.md`, `raw/*/.gitkeep`), die im Stamp stehen wie jede andere Datei und deshalb aktiv
|
||||||
|
ausgeschlossen werden müssen. Die Seite nennt jetzt drei Klassen und die engere praktische Regel:
|
||||||
|
überschreibe die verbatim ausgelieferten Dateien, *die diese Instanz nicht angefasst hat*.
|
||||||
|
|
||||||
|
Dazu eine Präzisierung in `tools/CONTRACT.md`, die vorher nirgends stand: nach `--keep-local`
|
||||||
|
wird der neue Stamp trotzdem vollständig geschrieben, trägt also die Release-Summe auch für
|
||||||
|
Dateien, die bewusst *nicht* geschrieben wurden. Der Stamp ist die Vergleichsbasis für den
|
||||||
|
nächsten Lauf, kein wörtliches Inventar der Platte - und genau das hält eine übersprungene Datei
|
||||||
|
bei jedem weiteren Lauf als abweichend gemeldet, statt sie nach einmaligem Überspringen still als
|
||||||
|
aktuell zu führen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4.0 - 2026-09-03 - Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Bisher bekam jeder `version bump` sofort eine fixierte Nummer, und weil `release.yml` auf jede
|
||||||
|
`VERSION`-Bewegung feuert, wurde daraus sofort ein Release: Nummern entstanden in
|
||||||
|
Commit-Granularität statt in Release-Granularität. Der 2026-09-03 hat so vier Releases in sechs
|
||||||
|
Stunden erzeugt (`4.3.0` bis `4.3.3`), zwei davon für reine Prosa-Änderungen - alle vier echt,
|
||||||
|
keines davon eine Einheit, an der ein Konsument sich hätte orientieren können. `VERSION` trägt
|
||||||
|
jetzt zwischen zwei Releases **einen** laufenden Kandidaten (`X.Y.Z-beta.N`):
|
||||||
|
`--major/--minor/--patch` eskaliert diesen Kandidaten max-wins gegen den letzten Release, statt
|
||||||
|
eine neue Nummer danebenzustellen, und geht dabei nie zurück.
|
||||||
|
|
||||||
|
`Version` versteht den Suffix, mit einer expliziten Ordnung
|
||||||
|
(`4.4.0-beta.1 < 4.4.0-beta.2 < 4.4.0`, numerisch nach `N`, nicht lexikografisch). `CHANGES.md`
|
||||||
|
trägt genau einen offenen Eintrag pro Kandidat: der erste Bump eröffnet ihn, jeder weitere
|
||||||
|
aktualisiert Heading und die maschinenverwaltete Bump-Titel-Liste in
|
||||||
|
`<!-- wikitool:bumps -->` (Marker-Konvention aus `blocks.py`, aber bewusst nicht in
|
||||||
|
`blocks.BLOCKS` - diese Region gehört zu `CHANGES.md`, nicht zu einer Seite). `version release`
|
||||||
|
ist neu und fixiert einen Kandidaten: Suffix weg, Eintrag geschlossen, committet und pusht nichts.
|
||||||
|
|
||||||
|
Vier Stellen am Bestand angepasst, die das Kandidatenmodell sonst still beschädigt hätten:
|
||||||
|
`release.yml` überspringt einen suffixbehafteten `VERSION`-Push sauber, bevor die Releases-API
|
||||||
|
gefragt wird, statt jeden Beta-Bump zu veröffentlichen; die Grenzübertritts-Checks in
|
||||||
|
`docs verify` (`check_migration_for_boundary`, `check_breaking_change_for_boundary`) messen jetzt
|
||||||
|
gegen den **letzten Release** (`version_mod.last_release`) statt gegen den zweitobersten Eintrag,
|
||||||
|
der zwischen zwei Betas keine Grenze mehr hergibt; `kb_state.chain()`/`next_link()` vergleichen
|
||||||
|
gegen die **Kandidatenbasis**, weil eine Migration mit Ziel `4.4.0` sonst bei installiertem
|
||||||
|
`4.4.0-beta.1` aus dem Intervall fällt (`4.4.0-beta.1 < 4.4.0`); `read_kb_version()` verweigert
|
||||||
|
einen Prerelease, weil eine Inhaltsform kein Beta kennt. `dist export` schreibt `VERSION` und den
|
||||||
|
Stamp weiterhin ehrlich mit Suffix, aber `.wikitool-kb.json` bekommt die Basis.
|
||||||
|
|
||||||
|
Menschendoku für die Erzeuger-Seite: `DEVELOPMENT.md` im Repo-Root, bewusst nicht in
|
||||||
|
`dist_cmd.ROOT_FILES` (Begründung als Kommentar dort), mit Zeile in `AGENTS.md` § File naming und
|
||||||
|
Zeiger aus `README.md`. `docs/version-model.md` hat einen neuen Abschnitt, warum eine Nummer erst
|
||||||
|
durch ein Release verbraucht wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3.3 - 2026-09-03 - Modellwahl nach Pruefbarkeit statt nach Aufgabenname; stack-dev bricht an den Phasenwechseln fuer den Model-Switch
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
`instructions/claude-code-model-selection.md` routete bisher nach Skill: eine Zeile "Stack
|
||||||
|
development -> Opus/high" fuer alles, was `tools/`, `types/` oder `instructions/` anfasst. Das ist
|
||||||
|
zu grob in beide Richtungen - es verteuert die lange, mechanische Mitte einer Stack-Sitzung, und es
|
||||||
|
sagt nichts darueber, dass Anfang und Ende derselben Sitzung anders zu behandeln sind.
|
||||||
|
|
||||||
|
**Die neue Achse ist "was faengt hier einen Fehler ab".** Wo ein Check in Code steht - `pytest`,
|
||||||
|
`docs verify`, `instructions verify`, CI, die Gates - kostet der Fehler eines schwaecheren Modells
|
||||||
|
eine Runde und faellt auf. Wo die einzige Durchsetzung eine Sitzung ist, die Prosa liest, faellt
|
||||||
|
derselbe Fehler gar nicht auf: er wird ausgeliefert und bleibt stehen. Das ist dasselbe Argument,
|
||||||
|
das `docs/why-gates-are-code.md` fuer Gates fuehrt, angewandt auf die Modellwahl.
|
||||||
|
|
||||||
|
Stack-Entwicklung ist damit **nicht mehr eine Zeile, sondern drei**:
|
||||||
|
|
||||||
|
| Phase | Was einen Fehler faengt | Modell |
|
||||||
|
|---|---|---|
|
||||||
|
| Design, Versionsstelle, Grenzuebertritts-Urteil | nichts | Opus/high |
|
||||||
|
| Code, Tests, mechanische Doku-Synchronisation | pytest, CI, `docs verify` | Sonnet/high |
|
||||||
|
| Issue-Abschluss, `docs/`-Veralterung, Changelog-Prosa | nichts, per Konstruktion | Opus/high |
|
||||||
|
|
||||||
|
Die Mitte ist die lange Phase und die mit den Checks - dort liegt die Ersparnis. Die beiden
|
||||||
|
Raender sind kurz (Minuten, nicht Stunden), haben aber keinen maschinellen Waechter: `wikitool`
|
||||||
|
kennt den Issue-Tracker bewusst nicht, und eine `docs/`-Seite traegt keinen normativen Satz, also
|
||||||
|
gibt es dort nichts zu verifizieren. Sie oben zu lassen ist billig und schuetzt genau die Arbeit,
|
||||||
|
die still scheitert.
|
||||||
|
|
||||||
|
Zwei Praezisierungen dazu: **Effort ist der billigere Hebel als das Modell** - `medium` steht fuer
|
||||||
|
Stack-Arbeit bewusst in keiner Zeile, weil Mehrdatei-Konsistenz das ist, was ein reduzierter
|
||||||
|
Effort zuerst aufgibt; `high` ist die Untergrenze, sobald mehr als eine Datei oder ein Contract
|
||||||
|
betroffen ist. Und die Asymmetrie ist benannt: eine unnoetige Opus-Phase kostet einmal Geld, eine
|
||||||
|
ungepruefte Sonnet-Phase kann etwas ausliefern, das nie wieder jemand ansieht.
|
||||||
|
|
||||||
|
**Damit die Tabelle ueberhaupt wirksam wird, braucht sie Haltepunkte.** Eine Sitzung kann ihr
|
||||||
|
eigenes Modell nicht wechseln - das ist `/model` und gehoert dem Nutzer. Eine Empfehlung, die
|
||||||
|
niemand zum richtigen Zeitpunkt ausspricht, aendert nichts. `instructions/dev/stack-dev/SKILL.md`
|
||||||
|
bekommt deshalb zwei ausdrueckliche Breaks:
|
||||||
|
|
||||||
|
- **Neuer Schritt 3** - "Settle the design before building", mit dem Angebot zum Wechsel nach
|
||||||
|
unten, sobald der Plan steht und die Arbeit mechanisch wird. Einmal aussprechen, dann so oder
|
||||||
|
so weiterarbeiten.
|
||||||
|
- **Schritt 6 (Abschluss) bricht in die Gegenrichtung** - ab dort greift wieder kein Check. Mit
|
||||||
|
der ausdruecklichen Auflage, die Arbeit **unabhaengig von der Antwort** zu tun: nach dem Publish
|
||||||
|
auf einen Modellwechsel zu blockieren wuerde genau den Zustand hinterlassen, den Schritt 6
|
||||||
|
verhindern soll. Lief die Phase auf dem billigeren Modell, gehoert das in die Uebergabe statt
|
||||||
|
ins Schweigen.
|
||||||
|
|
||||||
|
Ein auftauchender Grenzuebertritt ist unter den Decision points ebenfalls als Anlass zum Wechsel
|
||||||
|
nach oben benannt: `docs verify` prueft, dass ein Uebertritt sich dokumentiert, nie dass die
|
||||||
|
Stelle richtig gewaehlt war.
|
||||||
|
|
||||||
|
Die uebrigen Schritte sind unveraendert und nur umnummeriert (alt 3-5 -> neu 4-6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Nachfassen zu 4.1.2 (#44), das die Regel geschaerft, aber den Weg zu ihr nicht geaendert hat.
|
||||||
|
`instructions/dev/issue-tracking.md` bekam damals Schritt 7 ("Closing is the last body update,
|
||||||
|
not a comment"); `instructions/dev/stack-dev/SKILL.md` bekam nur eine umformulierte Zeile in
|
||||||
|
seiner Routing-Liste. Eine Stunde spaeter schloss #45 auf exakt dieselbe Weise: gruendlicher
|
||||||
|
Abschlusskommentar ueber einem Body mit unangehakten Kriterien.
|
||||||
|
|
||||||
|
**Die Ursache lag nicht am Text der Regel, sondern an ihrer Erreichbarkeit.** Die nummerierten
|
||||||
|
Schritte des Skills endeten bei "Verify before publishing". Ein Issue zu schliessen war ueberhaupt
|
||||||
|
kein Schritt - es hing an einem Zeiger *innerhalb* von Schritt 2, und Schritt 2 ist eine
|
||||||
|
Routing-Tabelle aus fuenf "read X before Y"-Eintraegen, keine Checkliste. Eine Sitzung folgt dem
|
||||||
|
Spine, den sie im Kontext hat; was nur hinter einem Link steht, wird genau in dem Moment nicht
|
||||||
|
aufgeschlagen, in dem es greift - am Ende einer langen Sitzung, wenn der Kontext am vollsten und
|
||||||
|
die verbleibende Instruktionsflaeche am duennsten ist.
|
||||||
|
|
||||||
|
Verschaerfend arbeitete der Blurb gegen seine eigene Regel: fett gesetzt war "keep it current as
|
||||||
|
the state moves, **not at the end**". Wer den Body unterwegs ungefaehr gepflegt hatte, las daraus
|
||||||
|
Konformitaet - der eigentliche Abschlusstest stand nur in der verlinkten Datei.
|
||||||
|
|
||||||
|
Geaendert:
|
||||||
|
|
||||||
|
- **Neuer Schritt 5 in `stack-dev/SKILL.md`** - "Close the issue with a body rewrite, not a
|
||||||
|
comment", mit dem Test inline (Kriterien abgehakt oder mit Begruendung gestrichen,
|
||||||
|
Entscheidungen als entschieden formuliert, kein Praesens ueber einen behobenen Defekt,
|
||||||
|
Verifikation benannt) und dem Verweis auf Schritt 7 fuer die volle Form. Damit steht der
|
||||||
|
Abschluss auf dem Spine.
|
||||||
|
- **Schritt-2-Blurb rebalanciert** - beide Haelften binden jetzt sichtbar: fortlaufende Pflege
|
||||||
|
*und* der Rewrite vor dem Schliessen, mit Verweis auf Schritt 5.
|
||||||
|
|
||||||
|
Nichts davon ist maschinell pruefbar, und das bleibt richtig so: `wikitool` kennt den Tracker
|
||||||
|
nicht und darf ihn nicht lernen, weil es an Instanzen ausliefert, die kein Board haben
|
||||||
|
(`issue-tracking.md` § "What no tool checks"). Der Skill-Spine ist die einzige Durchsetzung, die
|
||||||
|
es geben kann - was der Grund ist, den Schritt zu nummerieren statt ihn zu verlinken.
|
||||||
|
|
||||||
|
Verallgemeinerbar: eine Regel, die in eine verlinkte Instruction geschrieben wird, erreicht
|
||||||
|
Sitzungen nur, wenn die nummerierten Schritte des zustaendigen Skills sie in dem Moment
|
||||||
|
ansteuern, in dem sie greift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3.1 - 2026-09-03 - docs/ befuellt - Stack-Hintergrund fuer vier Themen, Pflegeklausel in AGENTS.md ergaenzt
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Gitea #45: die von #38 angelegte, bis dahin leere `docs/` bekommt ihre ersten vier Seiten - frisch
|
||||||
|
geschrieben, nicht durch Umzug aus `kb/` befuellt, jede ohne normativen Satz und mit Verweis auf
|
||||||
|
das bindende Dokument statt einer Wiederholung seiner Regeln:
|
||||||
|
|
||||||
|
- `docs/pipeline-rationale.md` - warum `raw -> types/tools -> kb -> reports` vier getrennte Stufen
|
||||||
|
sind und was "never re-derive, always compile" praktisch bedeutet
|
||||||
|
- `docs/why-gates-are-code.md` - warum Mass-Update-, Publish-Remote- und Iteration-Budget-Gate in
|
||||||
|
`tools/wikitool` statt in einer Instruktion stehen
|
||||||
|
- `docs/ownership-and-templates.md` - der Unterschied zwischen stack-eigenen, verbatim
|
||||||
|
ausgelieferten Dateien und instanz-eigenen `.template`-Dateien
|
||||||
|
- `docs/version-model.md` - warum Drop-in-Kompatibilitaet und Migrationsbedarf zwei unabhaengige
|
||||||
|
Fragen sind, illustriert an der 2.0.0-Fallstudie
|
||||||
|
|
||||||
|
**AGENTS.md § Changelog:** neue Klausel zur Pflege von `docs/`, ergaenzt neben der bestehenden
|
||||||
|
Regel zu `README.md`/`EVALS.md`/`tools/README.md`. Eine `docs/`-Seite veraltet nicht wie ein
|
||||||
|
README bei jedem neuen Flag, sondern nur, wenn die aufgeschriebene Begruendung selbst nicht mehr
|
||||||
|
traegt - per Konstruktion ungeprueft, da die Seite keinen normativen Satz enthaelt, den
|
||||||
|
`docs verify` pruefen koennte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3.0 - 2026-09-03 - docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Gitea #38: `dist export` lieferte bislang keine einzige `kb/`-Seite aus - eine frische Instanz
|
||||||
|
bekam den Stack, aber keinen Grund für seine Form. Die dokumentierte `adr-NNN-`-Konvention in
|
||||||
|
`kb/concepts/COLLECTION.md` existierte zudem nur auf Papier: keine der sieben
|
||||||
|
`concept_type: decision`-Seiten folgte ihr, und `confidence_decay()` lief bedingungslos über sie
|
||||||
|
- ein Kategorienfehler, weil Zeitablauf eine Entscheidung nicht falscher macht, nur Supersession
|
||||||
|
tut das.
|
||||||
|
|
||||||
|
**Neu:** `docs/` - ein inertes Verzeichnis für Stack-Hintergrund (warum der Stack so gebaut ist,
|
||||||
|
nicht was diese Instanz entschieden hat). Keine Frontmatter, kein Typ, kein Index, kein Lint,
|
||||||
|
keine Decay, keine Provenance, keine `COLLECTION.md`. `dist export` liefert es verbatim aus, wie
|
||||||
|
`instructions/` und `types/`. Befüllung folgt in Gitea #45.
|
||||||
|
|
||||||
|
**Verworfen, nach Prüfung:** ein Umzug der sieben Decision-Seiten nach `decisions/`. Der
|
||||||
|
Subtyp-Floor aus #28 verlangt mindestens eine Seite je deklariertem `concept_type`, und ein
|
||||||
|
Umzug hätte `decision` auf null gebracht; dazu zeigen 89 Wikilinks aus `kb/` sowie
|
||||||
|
tool-eigene Frontmatter-Arrays auf die sieben, und `links.py`/`xref add` kennen kein Ziel
|
||||||
|
außerhalb `kb/`. Die sieben bleiben in `kb/concepts/`, ebenso ein zweiter, separat erwogener
|
||||||
|
Rename (`docs verify` → `parity verify`) - der wäre nur nötig gewesen, wenn ein Befehl auf das
|
||||||
|
Verzeichnis `docs/` wirkt, und keiner tut das.
|
||||||
|
|
||||||
|
**Geändert:**
|
||||||
|
- `confidence_decay()` überspringt `concept_type: decision` strukturell (kategorische Ausnahme,
|
||||||
|
nicht als Brücke gebaut - Begründung im Docstring).
|
||||||
|
- `kb/concepts/COLLECTION.md` § Decisions ersetzt die tote ADR-Vorlage durch die real gelebte
|
||||||
|
Form: eine Entscheidung ist eine gewöhnliche Concept-Seite, organische Prosa, kein
|
||||||
|
`adr-NNN-`-Präfix, `**Status:**` optional, Supersession per `supersedes`-Link.
|
||||||
|
- `kb/CONVENTIONS.md` § Naming und `instructions/kb-profiles.md` (Profil `german`) korrigiert -
|
||||||
|
beide dokumentierten noch die verworfene `adr-NNN-`-Namensregel.
|
||||||
|
- `AGENTS.md` § File naming und § Routing: `docs/`-Zeile, plus die Regel, dass `docs/` keinen
|
||||||
|
normativen Satz trägt (das hält Invariante 8 heil - was binden würde, gehört in einen
|
||||||
|
Contract).
|
||||||
|
- `tools/CONTRACT.md`: Klarstellung, dass `docs verify` Dokumentations-Parität prüft, nicht das
|
||||||
|
`docs/`-Verzeichnis, sowie `docs/` in der `dist export`-Zeile ergänzt.
|
||||||
|
|
||||||
|
Additiv und in beide Richtungen drop-in: eine bestehende Instanz ohne `docs/` exportiert
|
||||||
|
weiterhin identisch (leerer `_copy_tree`-Treffer), eine Instanz mit `docs/` bekommt es ab jetzt
|
||||||
|
mitgeliefert. Kein Feld, kein Kommando ändert sein Verhalten für bestehenden Inhalt.
|
||||||
|
|
||||||
|
**Migration:** none required.
|
||||||
|
|
||||||
|
Berührt: `tools/chemenu/commands/confidence_decay.py`, `tools/chemenu/commands/dist_cmd.py`,
|
||||||
|
`tools/chemenu/tests/test_confidence_decay.py`, `tools/chemenu/tests/test_dist_cmd.py`,
|
||||||
|
`kb/concepts/COLLECTION.md`, `kb/CONVENTIONS.md`, `instructions/kb-profiles.md`, `AGENTS.md`,
|
||||||
|
`tools/CONTRACT.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2.0 - 2026-09-03 - Korpus-Kuratierungsrichtlinie: Untergrenzen und Leitplanke für reaktive Fixes
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Ein Demo-Korpus will klein und stabil sein, ein Testbett groß, unordentlich und in Bewegung -
|
||||||
|
dieses Repo verlangt seit der Veröffentlichung beides vom selben `kb/` (Gitea #28). Die Sitzung
|
||||||
|
vom 2026-09-02 hatte Fixture, `--with-demo` und ein zweites Repo bereits verworfen; offen blieb
|
||||||
|
nur, wie kuratiert "kuratiert genug" heißt und welche Leitplanke reaktive Fixes bekommen.
|
||||||
|
|
||||||
|
**Neu:** `instructions/dev/corpus-policy.md`. Fünf Untergrenzen, jede mit einer bestehenden
|
||||||
|
`wikitool`-Prüfung messbar, keine davon durch neuen Tool-Code: jeder Seitentyp und jeder
|
||||||
|
deklarierte Subtyp mit mindestens einer Seite, mindestens fünf Seiten mit mindestens drei
|
||||||
|
Quellen, ein bis zehn Orphan-Seiten, im Schnitt mindestens vier ausgehende Wikilinks pro Seite.
|
||||||
|
Gemessen am 2026-09-03: 181 Seiten, alle Typ-/Subtyp-Floors erfüllt, 12 Seiten mit ≥3 Quellen, 3
|
||||||
|
Orphans, Ø 6,2 ausgehende Links - der Korpus war bereits groß genug, ohne dass eine einzige
|
||||||
|
Seite eigens dafür angelegt werden musste. Eine Untergrenze wird nie durch eine erfundene Seite
|
||||||
|
gefüllt, sondern durch eine echte Quelle beim nächsten passenden Ingest - Invariante 3 gilt
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
**Die Leitplanke für reaktive Fixes** unterscheidet drei Stufen: punktuelle Änderungen (immer
|
||||||
|
erlaubt, gewöhnliche Arbeit), korpusweite Änderungen (nur geplant, mit eigenem Issue und
|
||||||
|
`work/`-Run - trifft eine Session das Mass-Update-Gate während sie etwas anderes tat, holt sie
|
||||||
|
sich nicht den `--confirm`-Token, sondern stoppt und legt ein Issue an) und reaktive Eingriffe
|
||||||
|
in Korpusinhalt, um einen Test grün zu machen oder einen Tool-Bug zu umgehen (nie erlaubt,
|
||||||
|
Invariante 7). Das Verhältnis zu `kb_dir`/`raw_dir` und `test_pipeline_l0.py` bleibt wie im
|
||||||
|
ursprünglichen Befund: kleiner, isolierter Fall in der Fixture, großer, vernetzter Fall in
|
||||||
|
`kb/` - keine Fixture-Extraktion aus dem Korpus.
|
||||||
|
|
||||||
|
Dev-only und rein additiv - kein Feld, kein Kommando, keine Datei außerhalb von
|
||||||
|
`instructions/dev/` ändert sich, daher `--minor` ohne `--breaking`.
|
||||||
|
|
||||||
|
**Migration:** none required.
|
||||||
|
|
||||||
|
Berührt: `instructions/dev/corpus-policy.md` (neu),
|
||||||
|
`instructions/dev/stack-dev/SKILL.md` (Schritt 2, Routing-Zeile).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Aufgefallen beim Schließen von #44: der Abschlussbericht stand als Kommentar da, der Body
|
||||||
|
darunter weiterhin als offene Arbeit — Abschnitt „Zu entscheiden" über eine längst getroffene
|
||||||
|
Entscheidung, ungehakte Checkliste, Präsens über einen Defekt, den es nicht mehr gab.
|
||||||
|
|
||||||
|
Die Regel gab es dafür schon: Schritt 2 von `instructions/dev/issue-tracking.md` sagt, der Body
|
||||||
|
ist die aktuelle Wahrheit und wird umgeschrieben, wenn sich der Stand ändert. Nur ließ die
|
||||||
|
Formulierung offen, *wann* — und Schritt 7 („Close with what actually happened") war vollständig
|
||||||
|
erfüllbar, ohne den Body anzufassen. Ein Abschlussbericht im Kommentar fühlt sich beim Schreiben
|
||||||
|
vollständig an; dass der Body dabei zurückbleibt, merkt erst der nächste Leser.
|
||||||
|
|
||||||
|
**Schritt 2 ist deshalb schärfer geworden: der Body ist das Plan-File dieses Stacks.** Dasselbe,
|
||||||
|
was das Plan-Dokument eines Harness ist, und genauso gepflegt — fortlaufend, sobald etwas darin
|
||||||
|
nicht mehr stimmt, nicht am Ende. Der Maßstab ist der Abbruch, nicht der Meilenstein: eine
|
||||||
|
Session kann jederzeit enden, und was der Body in diesem Moment sagt, ist die vollständige
|
||||||
|
Übergabe. Eine frische Session muss zu **jedem** Zeitpunkt allein aus dem Body weiterarbeiten
|
||||||
|
können, ohne Kommentare rückwärts zu lesen und ohne einen Menschen, der es neu erklärt. Entschieden
|
||||||
|
ersetzt die Frage, erledigt hakt das Kriterium ab, verworfen steht mit Begründung dort, wo das
|
||||||
|
Kriterium stand.
|
||||||
|
|
||||||
|
Schritt 7 ist damit kein Sonderakt mehr, sondern die letzte dieser Aktualisierungen: erst Body
|
||||||
|
auf den Endstand, dann schließen, dann die Changelog-Zeile aus Schritt 3. Wer Schritt 2 befolgt
|
||||||
|
hat, ist fast fertig; wer nicht, zahlt die ganze Schuld im schlechtesten Moment — der
|
||||||
|
geschlossene Body ist die Fassung, die danach alle lesen und niemand mehr aufsucht. #44 steht
|
||||||
|
als Beispiel drin.
|
||||||
|
|
||||||
|
Schritt 3 zieht die Konsequenz: **ein Kommentar pro Session-Umfang, nicht pro Edit.** Ein
|
||||||
|
fortlaufend gepflegter Body mit einem Changelog-Kommentar je Änderung wäre Lärm; triviale Pflege
|
||||||
|
braucht gar keinen. Der `stack-dev`-Skill sagt es beim Aufgreifen mit, weil dort die Entscheidung
|
||||||
|
fällt, ob eine Session den Body überhaupt anfasst.
|
||||||
|
|
||||||
|
**Und die ehrliche Antwort auf die Frage nach dem Tooling: es gibt keins, und es soll keins
|
||||||
|
geben.** `wikitool` kennt diesen Tracker nicht. Es wird an Instanzen ausgeliefert, die unter
|
||||||
|
dieser URL keine Issues haben, während `instructions/dev/` von `dist export` gepruned wird —
|
||||||
|
ein Gitea-Client im ausgelieferten Tool wäre eine Dev-Abhängigkeit, die jede Instanz mitträgt,
|
||||||
|
um ein Board zu prüfen, das keine von ihnen hat. Der Tracker ist ausschließlich über
|
||||||
|
`gitea-mcp` erreichbar, also in einer Session, durch einen Agenten.
|
||||||
|
|
||||||
|
Kein `docs verify` fängt also einen geschlossenen Issue, dessen Body offen klingt, einen Body,
|
||||||
|
der seinen eigenen Kommentaren widerspricht, oder ein fehlendes Pflichtlabel. Das steht jetzt
|
||||||
|
als eigener Abschnitt „What no tool checks" in der Instruktion — nicht als Bedauern, sondern als
|
||||||
|
Begründung dafür, warum die Reihenfolge in Schritt 7 ausgeschrieben ist statt aus Schritt 2
|
||||||
|
erschlossen zu werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1.1 - 2026-09-03 - Testisolation: kb_dir repointet config.ROOT, lint löst Kollektionen gegen den übergebenen Baum auf
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Issue #44, gefunden beim Bau der Migrations-Gate-Tests für 4.1.0: die `kb_dir`-Fixture baute
|
||||||
|
ihren Baum unter `tmp_path`, ließ `config.ROOT` aber auf dem echten Checkout stehen. Jeder
|
||||||
|
Codepfad, der eine Datei über `config.ROOT`/`config.KB_DIR` auflöst statt über das übergebene
|
||||||
|
Verzeichnis, traf damit das echte Repository.
|
||||||
|
|
||||||
|
**Der laute Fall** war ein Test, der `kb_state.write_kb_state()` rief und dabei das
|
||||||
|
`.wikitool-kb.json` des Repos überschrieb — Applied-Ledger leer statt zwei Einträgen. In
|
||||||
|
`git status` sofort sichtbar und reversibel; bei einer gitignorierten Datei wäre es das nicht
|
||||||
|
gewesen.
|
||||||
|
|
||||||
|
**Der stillere Fall** ist der teurere. `lint`s Kollektions-Lookup löste eine Seite gegen
|
||||||
|
`config.KB_DIR` auf. Für eine Seite unter `tmp_path/kb/` warf das `ValueError`, die Funktion
|
||||||
|
antwortete „keine Kollektion", und die Label-Autorisierung übersprang die Kante wortlos.
|
||||||
|
`unauthorised_labels` war damit faktisch ungetestet — jeder Test, der das Finding hätte
|
||||||
|
auslösen können, bekam eine leere Liste und behauptete nichts. Ein grüner Lauf, der wie eine
|
||||||
|
Zusicherung aussah.
|
||||||
|
|
||||||
|
**Der Fix ist der Codepfad, nicht die Fixture.** `run_lint()` bekommt ein Verzeichnis
|
||||||
|
übergeben und löst jetzt auch intern dagegen auf; `authorised_labels()` bekommt denselben Baum
|
||||||
|
gereicht, statt auf `config.KB_DIR` zurückzufallen. Der Regressionstest lintet einen Baum, von
|
||||||
|
dem `ROOT` bewusst wegzeigt — genau der Fall, den die alte Auflösung verschluckte. Eine Funktion,
|
||||||
|
die ein Verzeichnis entgegennimmt, löst dagegen auf: keine Fixture kann diese Form von außen
|
||||||
|
reparieren.
|
||||||
|
|
||||||
|
**Beide Korpus-Fixturen repointen jetzt.** `kb_dir` tut, was `raw_dir` längst tat — `ROOT` auf
|
||||||
|
das eigene `tmp_path`, plus `use_shipped_type_specs()`. Der Suite-Lauf kippte dadurch keinen
|
||||||
|
einzigen Test. Die lokale `rooted_kb`-Umgehung aus 4.1.0 entfällt damit; die Auswahl zwischen
|
||||||
|
zwei fast gleichen Fixturen war Wissen, das nirgends stand.
|
||||||
|
|
||||||
|
**Und ein Wächter für die ganze Klasse.** `repository_tree_guard` (session-scoped, autouse)
|
||||||
|
vergleicht `git status --porcelain` vor und nach dem Lauf und lässt die Suite scheitern, wenn
|
||||||
|
sich im Checkout etwas bewegt hat — zwei `git status`-Aufrufe pro Lauf, deshalb per Default an.
|
||||||
|
Er vergleicht vorher gegen nachher statt einen sauberen Baum zu verlangen, sagt also nichts über
|
||||||
|
die unveröffentlichte Arbeit des Entwicklers. Den Verursacher benennt er nicht;
|
||||||
|
`CHEMENU_TREE_GUARD=each` prüft nach jedem Test und tut es. Ohne git oder außerhalb eines
|
||||||
|
Repositorys sind beide still.
|
||||||
|
|
||||||
|
Was der Wächter nicht sieht: eine Prüfung, die unter Test nichts tut, schreibt keine Datei.
|
||||||
|
Dagegen hilft nur ein Test, der das Finding tatsächlich auslöst — der neue tut das.
|
||||||
|
|
||||||
|
`instructions/dev/testing-conventions.md` hat dafür einen eigenen Abschnitt („Which tree a test
|
||||||
|
writes into"), einen Schritt in der Checkliste und die Regel für neue Fixturen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Der Rest von Issue #40, nachdem die Korpus-Migration durch ist: die beiden aufgeschobenen
|
||||||
|
Lint-Findings werden hart, und die drei Befunde aus dem Abschlusskommentar des Migrationslaufs
|
||||||
|
werden aufgelöst.
|
||||||
|
|
||||||
|
**`unlabelled_edges` und `unauthorised_labels` sind harte Fehler — aber an `kb_version`
|
||||||
|
gebunden, nicht an ein Datum.** Der Weg, den `legacy_citation_markers` genommen hat, war ein
|
||||||
|
Umlegen in einer späteren Version: eine Instanz, die die Zitat-Migration noch schuldete, lebte
|
||||||
|
danach mit rotem Lint. Das Ledger kann die Frage inzwischen beantworten, also tut es das.
|
||||||
|
Unterhalb `kb_version` 4.0.0 bleiben beide beratend — genau das Fenster, in dem
|
||||||
|
`instructions/migrations/4.0.0-link-taxonomy.md` der Instanz sagt, sie solle den halb
|
||||||
|
konvertierten Korpus Einheit für Einheit publizieren; ein Check, der dabei fehlschlägt, würde
|
||||||
|
den Korpus verweigern, dessen Fortschritt er misst. Ab 4.0.0 ist eine kahle Titelangabe in
|
||||||
|
`related:` keine Seite mehr, die auf ihre Umstellung wartet, sondern eine Kante, deren Autor
|
||||||
|
nicht gesagt hat, was sie behauptet. `hard_error_keys()` liefert die jeweils geltende Menge,
|
||||||
|
`HARD_ERROR_KEYS` bleibt die vollständige.
|
||||||
|
|
||||||
|
**`outbound:` ist an das Type-Spec gebunden.** `kb/sources/` und `kb/comparisons/`
|
||||||
|
autorisierten Label, die dort strukturell nicht schreibbar waren: keiner der beiden Type-Specs
|
||||||
|
führte ein `related:`. Folgenlos war das nicht — die einzige Comparison-Seite des Korpus trug
|
||||||
|
`- **compares-with:** [[amd-pstate]]` als *handgeschriebene Prosa*, ohne Marker-Region, ohne
|
||||||
|
Frontmatter, für `lint` unsichtbar. Also ein Identifier zurück im Fließtext, gut vier Stunden
|
||||||
|
nachdem 4.0.0 genau das beendet hatte. Eine leere Autorisierung liest sich als Lizenz.
|
||||||
|
|
||||||
|
Aufgelöst nach dem, was die beiden Contracts jeweils selbst sagen: `comparison` bekommt ein
|
||||||
|
`related:` (die `compares-with`-Kante gegen jedes Subjekt ist die eine Aussage, für die die
|
||||||
|
Seite existiert), `kb/sources/` verliert seinen `outbound:`-Block ersatzlos (dessen Contract
|
||||||
|
sagt ausdrücklich, seine Verknüpfungen seien der mechanische Provenance-Pfad und keine
|
||||||
|
Autorenkanten). Neu prüft `docs verify` die Kombination: ein `outbound:`-Block auf einer
|
||||||
|
Collection, in die kein Typ mit `related:` schreibt, ist ein Befund und nennt beide Richtungen
|
||||||
|
der Reparatur.
|
||||||
|
|
||||||
|
**`composition` / `part-of` ist das dritte Inversenpaar**, neben `depends-on` / `required-by`
|
||||||
|
und `runs-on` / `hosts`. Aus der Messung, nicht vom Schreibtisch: der u3-Lauf hatte entschieden,
|
||||||
|
die Gegenseite eines `composition` bekomme `see-also`, weil `part-of` ein Spiegel wäre. Ist es
|
||||||
|
nicht — der Satz des Elternteils zählt seine Teile auf, der des Kindes benennt das Ganze, zu
|
||||||
|
dem es gehört, und ein Leser, der auf dem Kind landet, braucht den zweiten. Übrig blieben 16
|
||||||
|
`see-also`-Kanten für eine Beziehung, für die der Katalog ein Wort hat; sie sind auf `part-of`
|
||||||
|
umgestellt. Ein Inversenpaar macht die Gegenkante weiterhin **nicht** zur Pflicht — Richtung
|
||||||
|
wird verfasst, nicht gespiegelt —, es legt nur fest, welches Label sie trägt, wenn jemand sie
|
||||||
|
schreibt.
|
||||||
|
|
||||||
|
**Stack- und Korpusänderung laufen hier in einem Zug**, entgegen der sonstigen Trennung. Der
|
||||||
|
neue `docs verify`-Check würde eine bestehende 4.0.x-Instanz beim bloßen Kopieren der neuen
|
||||||
|
Maschinerie fehlschlagen lassen, weil deren `kb/sources/COLLECTION.md` den `outbound:`-Block
|
||||||
|
noch trägt — nach [instructions/dev/version-parts.md](instructions/dev/version-parts.md)
|
||||||
|
Schritt 1 ein Grenzübertritt. Statt dafür eine `5.0.0` zu lösen, ist die Ursache mitbeseitigt:
|
||||||
|
die Collection-Contracts dieser Instanz sind angepasst, und `dist export` leitet die
|
||||||
|
`COLLECTION.md.template` daraus ab, also liefert jede neue Distribution die korrigierte Form
|
||||||
|
aus. Für eine bereits bestehende 4.0.x-Instanz bleibt eine Handbewegung übrig, und sie wird
|
||||||
|
hier benannt statt versteckt: die zwei `outbound:`-Zeilen aus `kb/sources/COLLECTION.md`
|
||||||
|
löschen. Das neue `related:` im `comparison`-Type-Spec erreicht sie ohnehin nicht — die vier
|
||||||
|
Page-Type-Specs gehören seit 4.0.0 der Instanz und werden nur als `.template` ausgeliefert.
|
||||||
|
|
||||||
|
Offen aus #40 bleibt nichts mehr; Befund 2 des Migrationslaufs (dem Katalog fehlt ein Register
|
||||||
|
für Urheberschaft) ist als eigenes Issue erfasst.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4.0.1 - 2026-09-02 - Issue-Board: vier Pflicht-Label-Familien und Body-als-Wahrheit
|
## 4.0.1 - 2026-09-02 - Issue-Board: vier Pflicht-Label-Familien und Body-als-Wahrheit
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
# Entwicklung dieses Stacks
|
||||||
|
|
||||||
|
Dieses Dokument richtet sich an Menschen, die an `tools/wikitool`, dem Type-Schema oder der
|
||||||
|
Instruction-/Skill-Schicht selbst arbeiten - nicht an den Konsumenten einer Instanz. Für die
|
||||||
|
Gegenseite (eine Instanz installieren, aktualisieren, betreiben) siehe [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
**Diese Datei wird nicht ausgeliefert.** Sie ist das menschliche Gegenstück zu
|
||||||
|
`instructions/dev/`, das `tools/wikitool dist export` vollständig ausschließt: eine
|
||||||
|
ausgelieferte Instanz hat keinen Release-Workflow, keine CI und kein Issue-Board, also braucht
|
||||||
|
sie auch keine Anleitung dafür. `dist_cmd.ROOT_FILES` listet sie deshalb bewusst nicht - der
|
||||||
|
Grund steht dort als Kommentar, damit eine spätere Sitzung die vermeintliche Lücke nicht
|
||||||
|
"repariert". Und weil sie nicht ausgeliefert wird, darf sie - anders als `README.md`,
|
||||||
|
`INSTALL.md` oder `EVALS.md`, die `instructions verify` auf genau diesen Punkt prüft - nach
|
||||||
|
`instructions/dev/` verlinken.
|
||||||
|
|
||||||
|
## Der Release-Ablauf
|
||||||
|
|
||||||
|
Zwischen zwei Releases führt der Stack **einen** laufenden Versionskandidaten statt einer neuen
|
||||||
|
Nummer pro Bump. Das volle Modell - Zustandsort, Eskalationslogik, warum eine Nummer erst durch
|
||||||
|
ein Release verbraucht wird - steht in
|
||||||
|
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) und
|
||||||
|
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||||
|
eine Sitzung ihn tatsächlich durchläuft:
|
||||||
|
|
||||||
|
1. **Bump eröffnet oder eskaliert den Kandidaten.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
|
||||||
|
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
|
||||||
|
denselben Eintrag, statt einen neuen zu eröffnen.
|
||||||
|
|
||||||
|
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
|
||||||
|
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
||||||
|
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
||||||
|
|
||||||
|
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools && .venv/bin/python -m pytest -q
|
||||||
|
tools/wikitool docs verify
|
||||||
|
tools/wikitool instructions verify
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **`version release` fixiert den Kandidaten**, sobald er ausgeliefert werden soll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool version release --title "Zusammenfassender Titel"
|
||||||
|
```
|
||||||
|
|
||||||
|
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
|
||||||
|
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
|
||||||
|
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
|
||||||
|
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
|
5. **Publish bewegt `VERSION` auf `main`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool publish --message "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Mass-Update-Gate und das Publish-Remote-Gate gelten wie bei jedem anderen Publish -
|
||||||
|
siehe [instructions/gates.md](instructions/gates.md).
|
||||||
|
|
||||||
|
6. **CI übernimmt den Rest.** `.gitea/workflows/release.yml` reagiert auf jeden Push, der
|
||||||
|
`VERSION` bewegt: Eine suffixbehaftete `VERSION` (ein Kandidat) lässt den Job sauber
|
||||||
|
überspringen, bevor er die Releases-API überhaupt anfragt - Betas werden nie veröffentlicht.
|
||||||
|
Eine suffixfreie `VERSION` baut die Distribution (`dist export`), erzeugt Tag und Release und
|
||||||
|
lädt Tarball plus Prüfsumme hoch. **CI setzt den Tag, nie eine Sitzung** - das hält
|
||||||
|
Invariante 5 intakt.
|
||||||
|
|
||||||
|
Die drei Verify-Befehle stehen oben in Schritt 3; was jeder von ihnen prüft, steht in
|
||||||
|
[tools/CONTRACT.md](tools/CONTRACT.md) und wird dort von `docs verify` gegen die tatsächliche
|
||||||
|
CLI gehalten. Hier steht es bewusst **nicht** noch einmal: eine zweite Beschreibung derselben
|
||||||
|
Befehle ist genau die Kopie, die driftet (AGENTS.md Invariante 8), und dieses Dokument liegt
|
||||||
|
außerhalb der Dateien, die der Kommandotabellen-Check von `docs verify` abdeckt - hier fällt eine
|
||||||
|
Drift also niemandem auf. Was `pytest` an dieser Stelle vom Entwickler erwartet, steht in
|
||||||
|
[instructions/dev/testing-conventions.md](instructions/dev/testing-conventions.md).
|
||||||
|
|
||||||
|
## Die CI-Hälfte
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` läuft auf jeden Push/PR gegen `main` (Content-Pfade ausgenommen) und
|
||||||
|
führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständigen
|
||||||
|
`setup-instance.md`-Replay gegen einen frischen `dist export` aus - derselbe Pfad, den ein neuer
|
||||||
|
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
||||||
|
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
||||||
|
|
||||||
|
## Stack-Entwicklung als eigener Sitzungstyp
|
||||||
|
|
||||||
|
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
||||||
|
Regeln für eine Sitzung, die den Stack selbst statt Wiki-Inhalt bearbeitet: wann
|
||||||
|
Quellenbindung nicht gilt, wo Design endet und die mechanische Phase beginnt (mit dem
|
||||||
|
Modellwechsel-Hinweis), und endet mit dem Publish. Die Schlussphase - Issue-Body als Rewrite
|
||||||
|
statt Kommentar, `docs/`-Veralterung, die Modell-Handover-Zeile über die ganze Sitzung - liegt
|
||||||
|
seit `4.6.0` in einem eigenen Folge-Skill, `stack-close`, den `stack-dev` an dieser Stelle
|
||||||
|
übergibt statt sie als weiteren eigenen Schritt zu führen. Siehe
|
||||||
|
[instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md) für den
|
||||||
|
Issue-Tracker selbst.
|
||||||
@@ -249,29 +249,49 @@ created but not yet written reports broken links. That is the scaffold saying it
|
|||||||
|
|
||||||
### How much of the stack the suite reaches
|
### How much of the stack the suite reaches
|
||||||
|
|
||||||
Coverage is measured in CI and reported, never enforced - `pytest --cov`, config in
|
Coverage is measured in CI - `pytest --cov`, config in `tools/.coveragerc`, HTML and XML
|
||||||
`tools/.coveragerc`, HTML and XML uploaded as the `coverage-<run id>` artifact of every run.
|
uploaded as the `coverage-<run id>` artifact of every run.
|
||||||
There is no `--cov-fail-under`: a threshold is owed (Gitea #10), in its own commit, once the
|
**Fetch that artifact from the run's own page, not from the API**: `upload-artifact@v3` writes
|
||||||
number has been watched long enough to freeze the state it actually reached.
|
through the older artifact API, and the Actions artifact REST endpoints answer `total_count: 0`
|
||||||
|
for a run whose artifact the run page offers for download. The upload works; only the listing
|
||||||
|
does not see it. Do not re-derive this, and do not read the empty list as a failed upload.
|
||||||
|
|
||||||
**First measurement, 2026-08-31, stack 1.8.1: 86.9% of 5105 statements across `chemenu/`,
|
It is enforced at a floor of **85%** (`fail_under` in `tools/.coveragerc`), which is what a red
|
||||||
730 tests** - as reported by CI run 87, not by the local run that preceded the last commit of
|
suite from this axis means: coverage actually fell, not that a wrapper was added. The floor was
|
||||||
that release. Reproduce it with `cd tools && .venv/bin/python -m pytest -q --cov` (needs
|
set only after the number had been watched - Gitea #10 held it back for exactly that, and the
|
||||||
`pytest-cov`, which is CI-only and deliberately absent from `tools/requirements.txt` - an
|
two points between 85 and the measured 87.0% are the room the taxonomy below asks for. A
|
||||||
instance runs the wiki, it does not measure this suite).
|
threshold at the measured number goes red on the next thin Typer wrapper, and a threshold that
|
||||||
|
goes red for a non-reason gets lowered rather than earned.
|
||||||
|
|
||||||
The total is the least interesting number here. What the report is for is *which* modules sit
|
**Measured 2026-09-04, stack 4.7.1: 87.0% of 6498 statements across `chemenu/`, 975 tests** -
|
||||||
|
CI run 163. The first measurement, at stack 1.8.1 on 2026-08-31, was 86.9% of 5105 statements
|
||||||
|
over 730 tests (CI run 87). Both are what CI reported, never a local run: the local number
|
||||||
|
preceding a release measures a tree that is one commit short of the published one.
|
||||||
|
|
||||||
|
The pair says more than either number does. Between them the measured code grew by a quarter
|
||||||
|
and the suite by a third, and the quota moved by a tenth of a point - which is the observation a
|
||||||
|
threshold was waiting for, rather than the total itself. Reproduce either with
|
||||||
|
`cd tools && .venv/bin/python -m pytest -q --cov` (needs `pytest-cov`, which is CI-only and
|
||||||
|
deliberately absent from `tools/requirements.txt` - an instance runs the wiki, it does not
|
||||||
|
measure this suite).
|
||||||
|
|
||||||
|
The total stays the least interesting number here. What the report is for is *which* modules sit
|
||||||
low, and three kinds have to be told apart before any of it turns into work:
|
low, and three kinds have to be told apart before any of it turns into work:
|
||||||
|
|
||||||
- **Thin Typer wrappers**, where the logic lives beside them and is tested there:
|
- **Thin Typer wrappers**, where the logic lives beside them and is tested there:
|
||||||
`eval_cmd.py` (36%), `types_cmd.py` (52%), `cli.py` (52%). Low coverage on a wrapper is
|
`eval_cmd.py` (36%), `types_cmd.py` (40%), `search.py` (49%), `cli.py` (54%),
|
||||||
evidence of a good cut, not of a missing test.
|
`links_cmd.py` (61%). Low coverage on a wrapper is evidence of a good cut, not of a missing
|
||||||
|
test - `search.py`'s uncovered block is its command body alone, while the backends under
|
||||||
|
`chemenu/search/` that do the work sit between 91% and 98%.
|
||||||
- **Code that reaches the network or the filesystem's outside**, where the interesting half is
|
- **Code that reaches the network or the filesystem's outside**, where the interesting half is
|
||||||
already injectable and tested through the seam: `version.py`'s `fetch_latest()` takes a
|
already injectable and tested through the seam: `version.py`'s `fetch_latest()` takes a
|
||||||
`fetcher` parameter for exactly that, and the real network line stays uncovered on purpose.
|
`fetcher` parameter for exactly that, and the real network line stays uncovered on purpose.
|
||||||
- **Genuine gaps**, where uncovered lines are logic nobody exercises: `provenance_cmd.py`
|
- **Genuine gaps**, where uncovered lines are logic nobody exercises: `provenance_cmd.py`
|
||||||
(44%), `migrate_cmd.py` (71%), `type_resolver.py` (79%). This is the list worth reading, and
|
(44%), `migrate_cmd.py` (65%), `type_resolver.py` (79%). This is the list worth reading, and
|
||||||
the reason step 2 of #10 is not a formality.
|
the only one of the three that has not moved while everything around it did:
|
||||||
|
`provenance_cmd.py` sits where it sat, and `migrate_cmd.py` fell from 71% because it grew and
|
||||||
|
its new lines arrived untested. The floor freezes this; it does not close it. Closing it is
|
||||||
|
Gitea #51.
|
||||||
|
|
||||||
## Scoring a session
|
## Scoring a session
|
||||||
|
|
||||||
|
|||||||
+71
-19
@@ -167,10 +167,17 @@ wenn nicht). `tools/wikitool version notes` druckt den Eintrag.
|
|||||||
|
|
||||||
### Eine Instanz aktualisieren
|
### Eine Instanz aktualisieren
|
||||||
|
|
||||||
Das Anwenden eines Updates ist ein bewusst manueller Vorgang - es schreibt in eine Instanz, die
|
Zwei Wege, je nachdem, wie diese Instanz entstanden ist. Ein **Clone mit gemeinsamer
|
||||||
bereits Inhalt hat. Der Inhalt hat dabei eine **eigene Version**: `.wikitool-kb.json` sagt, in
|
Git-History** (`upstream`-Remote auf das Ursprungs-Repo, siehe
|
||||||
welcher Form die Seiten vorliegen, unabhängig davon, welche Maschinerie danebensteht. Genau
|
[instructions/private-instance.md](instructions/private-instance.md)) nimmt Stack-Updates per
|
||||||
dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade befindet.
|
echtem Drei-Wege-Merge: `tools/wikitool upstream merge`. Alles Folgende gilt für eine **Instanz
|
||||||
|
aus einem Tarball**, ohne gemeinsame History - der Weg unten unter „Eine Instanz aktualisieren"
|
||||||
|
nutzt sie.
|
||||||
|
|
||||||
|
Das Anwenden eines Updates schreibt in eine Instanz, die bereits Inhalt hat. Der Inhalt hat dabei
|
||||||
|
eine **eigene Version**: `.wikitool-kb.json` sagt, in welcher Form die Seiten vorliegen,
|
||||||
|
unabhängig davon, welche Maschinerie danebensteht. Genau dieser Unterschied ist der Zustand, in
|
||||||
|
dem sich jede Instanz mitten im Upgrade befindet.
|
||||||
|
|
||||||
1. **Vor dem Tausch** prüfen, was ansteht - solange `VERSION` noch die alte ist:
|
1. **Vor dem Tausch** prüfen, was ansteht - solange `VERSION` noch die alte ist:
|
||||||
|
|
||||||
@@ -178,19 +185,49 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b
|
|||||||
tools/wikitool migrate status
|
tools/wikitool migrate status
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Release-Tarball herunterladen und entpacken (Weg A), die Release-Notes lesen.
|
Steht hier etwas aus, erst diese Migrationskette abschließen (Schritt 5 unten) - `dist upgrade`
|
||||||
3. Die **Maschinerie** aus dem Tarball über die Instanz kopieren: `tools/`, `types/`,
|
verweigert den Tausch sonst von selbst.
|
||||||
`instructions/`, `AGENTS.md`, `VERSION`, `.wikitool-release.json` - **und `kb/CONTRACT.md`**.
|
|
||||||
Die letzte Datei liegt unter einem Content-Verzeichnis, ist aber Stack-Eigentum: sie hält,
|
2. Release-Tarball herunterladen und die Release-Notes lesen (Weg A oben).
|
||||||
was `wikitool` erzwingt, und ist in jeder Instanz gleich. Nicht anfassen: alles andere unter
|
3. **Maschinerie tauschen:**
|
||||||
`kb/` und `raw/`, `work/`, `.wikitool-kb.json` und `.git/` - das ist die Instanz selbst,
|
|
||||||
`kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` eingeschlossen.
|
```bash
|
||||||
4. Achtung bei lokal angepassten Stack-Dateien. Die Autorenkonventionen gehören **nicht** dazu:
|
tools/wikitool dist upgrade <tarball-oder-verzeichnis> --dry-run
|
||||||
`kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` liegen unter `kb/`, werden in Schritt 3
|
```
|
||||||
also ohnehin nicht angefasst - genau dafür ist der Schnitt da. Wer darüber hinaus etwas
|
|
||||||
unter `tools/`, `types/` oder `instructions/` verändert hat, sichert das vorher und spielt
|
**Beim ersten Sprung auf `4.5.0` oder höher gibt es dieses Kommando in der Instanz noch
|
||||||
es danach wieder ein. Welche Dateien das sind, verrät ein Vergleich gegen die sha256-Summen
|
nicht** - es kam erst mit `4.5.0`. Dann das Werkzeug aus dem entpackten *neuen* Tarball
|
||||||
im `files`-Block der alten `.wikitool-release.json`.
|
verwenden, gegen die alte Instanz gerichtet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf chemenu-stack-<version>.tar.gz
|
||||||
|
CHEMENU_ROOT="$PWD" chemenu-stack-<version>/tools/wikitool \
|
||||||
|
dist upgrade chemenu-stack-<version>.tar.gz --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`CHEMENU_ROOT` sagt dem Paket, auf welchen Korpus es zeigen soll (siehe § Konfiguration);
|
||||||
|
ohne die Variable würde es den entpackten Tarball selbst für die Instanz halten. Ab dem
|
||||||
|
zweiten Upgrade trägt die Instanz das Kommando selbst und die kurze Form oben genügt.
|
||||||
|
|
||||||
|
Klassifiziert jede Datei aus dem `files`-Block der neuen `.wikitool-release.json`:
|
||||||
|
unverändert seit der Installation, lokal verändert oder gelöscht, neu im Release, oder aus dem
|
||||||
|
Release entfallen - und druckt die Migrationskette, die nach dem Tausch aussteht, ohne sie
|
||||||
|
auszuführen. Ohne `--dry-run` schreibt der Befehl; eine lokal veränderte oder gelöschte Datei
|
||||||
|
wird dabei **nie** stillschweigend überschrieben - der Lauf bricht mit der vollständigen Liste
|
||||||
|
ab, es sei denn `--keep-local` ist gesetzt (dann bleibt jede davon unangetastet, erneut
|
||||||
|
gemeldet). `--prune` entfernt zusätzlich Dateien, die der neue Release nicht mehr ausliefert
|
||||||
|
und die seit der Installation unverändert sind. Voraussetzungen: ein sauberer Arbeitsbaum
|
||||||
|
(kein Git-Repo ist ein WARN, keine Sperre), eine lokale `.wikitool-release.json` mit
|
||||||
|
`files`-Block (fehlt sie, siehe „Fallstricke" unten), und `.wikitool-kb.json` vorhanden.
|
||||||
|
Committet und pusht nichts (Invariante 5). Vollständiger Fehlerkontrakt:
|
||||||
|
[tools/CONTRACT.md](tools/CONTRACT.md).
|
||||||
|
|
||||||
|
Eine lokal veränderte Stack-Datei ist damit sichtbar, statt von Hand gegen die sha256-Summen
|
||||||
|
im `files`-Block geprüft werden zu müssen - genau der Schritt, der vor `4.5.0` hier stand.
|
||||||
|
4. Bei einer Kompatibilitätsgrenze (`dist upgrade` meldet sie laut) die Release-Notes vor dem
|
||||||
|
nächsten Schritt lesen: **Breaking Change:** und **Migration:** im Eintrag von
|
||||||
|
`tools/wikitool version notes` sagen, was aufhört zu funktionieren und ob der Korpus
|
||||||
|
umgeschrieben werden muss.
|
||||||
5. **Die Migrationskette abarbeiten.** `tools/wikitool migrate status` listet jetzt alle
|
5. **Die Migrationskette abarbeiten.** `tools/wikitool migrate status` listet jetzt alle
|
||||||
offenen Migrationen in der Reihenfolge, in der sie laufen müssen - bei einem Sprung über
|
offenen Migrationen in der Reihenfolge, in der sie laufen müssen - bei einem Sprung über
|
||||||
mehrere Versionen sind das mehrere. Für jede: das genannte Dokument unter
|
mehrere Versionen sind das mehrere. Für jede: das genannte Dokument unter
|
||||||
@@ -204,15 +241,30 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b
|
|||||||
`done` verweigert jede Version, die nicht das nächste Glied ist - eine übersprungene
|
`done` verweigert jede Version, die nicht das nächste Glied ist - eine übersprungene
|
||||||
Migration hinterlässt einen Korpus in einer Form, die keine Version beschreibt. Ein
|
Migration hinterlässt einen Korpus in einer Form, die keine Version beschreibt. Ein
|
||||||
abgebrochenes Upgrade wird durch erneutes `migrate status` fortgesetzt.
|
abgebrochenes Upgrade wird durch erneutes `migrate status` fortgesetzt.
|
||||||
6. Prüfen: `tools/wikitool migrate verify --from <commit vor der Migration>`, dann `doctor`,
|
6. Prüfen: `tools/wikitool migrate verify --from <commit vor dem Tausch>`, dann `doctor`,
|
||||||
`docs verify`, `instructions verify` und `lint`. Zum Schluss
|
`docs verify`, `instructions verify` und `lint`. Zum Schluss
|
||||||
`tools/wikitool instructions sync` (die Skills sind Kopien) und die Agent-Session neu
|
`tools/wikitool instructions sync` (die Skills sind Kopien) und die Agent-Session neu
|
||||||
starten.
|
starten. `dist upgrade` nennt diese Reihenfolge im eigenen Abschlussbericht, führt aber keinen
|
||||||
|
der Schritte selbst aus.
|
||||||
|
|
||||||
`doctor` warnt, solange `kb_version` hinter `VERSION` zurückliegt und noch Migrationen offen
|
`doctor` warnt, solange `kb_version` hinter `VERSION` zurückliegt und noch Migrationen offen
|
||||||
sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei ganz - dann einmalig
|
sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei ganz - dann einmalig
|
||||||
`tools/wikitool migrate baseline <version>` aufrufen; geraten wird nichts.
|
`tools/wikitool migrate baseline <version>` aufrufen; geraten wird nichts.
|
||||||
|
|
||||||
|
**Fallstricke.** Eine Instanz ohne lokale `.wikitool-release.json` (oder eine ohne `files`-Block,
|
||||||
|
aus der Zeit vor `4.5.0`) hat für `dist upgrade` keine Basis, gegen die es eine lokale Änderung
|
||||||
|
erkennen könnte, und verweigert den Tausch - dafür gibt es heute keine Reparatur (siehe Gitea #7).
|
||||||
|
Der Befehl lädt selbst nichts herunter: `<tarball-oder-verzeichnis>` muss vorher aus Weg A
|
||||||
|
geholt werden, und ein Tarball muss genau ein Top-Level-Verzeichnis enthalten - die Form, in der
|
||||||
|
`.gitea/workflows/release.yml` es baut.
|
||||||
|
|
||||||
|
Vor `4.5.0` stand hier ein rein manueller Ablauf (Maschinerie von Hand kopieren, `kb/CONTRACT.md`
|
||||||
|
eingeschlossen, sha256-Vergleich von Hand). `dist upgrade` ersetzt genau diesen Teil; wer ihn
|
||||||
|
dennoch von Hand nachvollziehen will oder muss (ein Werkzeug, das `wikitool` selbst nicht
|
||||||
|
ausführen kann), findet die Dateiliste im `files`-Block der `.wikitool-release.json` und die
|
||||||
|
Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
|
||||||
|
[tools/CONTRACT.md](tools/CONTRACT.md)s `dist upgrade`-Zeile.
|
||||||
|
|
||||||
### Sonderfall: Update von 1.x auf 2.0.0
|
### Sonderfall: Update von 1.x auf 2.0.0
|
||||||
|
|
||||||
Mit `2.0.0` wurde das Ursprungs-Repo von `torben/llm-wiki-test1` auf `torben/chemenu`
|
Mit `2.0.0` wurde das Ursprungs-Repo von `torben/llm-wiki-test1` auf `torben/chemenu`
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ chemenu/
|
|||||||
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
├── DEVELOPMENT.md # Human-readable: the release workflow (version bump/release/publish/CI)
|
||||||
└── commonplace/ # Vendored, read-only knowledge base
|
└── commonplace/ # Vendored, read-only knowledge base
|
||||||
```
|
```
|
||||||
<!-- dist:strip-end -->
|
<!-- dist:strip-end -->
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Ownership and Templates
|
||||||
|
|
||||||
|
Chemenu ships two kinds of files side by side, and at a glance they look the same: both are
|
||||||
|
plain markdown, both sit in the repo root or under `kb/`, both get read at session start. But a
|
||||||
|
stack upgrade treats them completely differently. Some - [AGENTS.md](../AGENTS.md),
|
||||||
|
[kb/CONTRACT.md](../kb/CONTRACT.md), the per-stage contracts - are identical in every instance
|
||||||
|
that runs this stack and are the next release's to replace (with one caveat about local edits,
|
||||||
|
below). Others - `USER.md`,
|
||||||
|
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
||||||
|
overwriting them would silently erase a choice someone made on purpose.
|
||||||
|
|
||||||
|
## Two different kinds of truth
|
||||||
|
|
||||||
|
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
||||||
|
what `tools/wikitool` enforces or what follows mechanically from how it operates - see
|
||||||
|
[kb/CONTRACT.md](../kb/CONTRACT.md), lines 10-13. That kind of statement doesn't vary by
|
||||||
|
instance: the compiler behaves the same way regardless of who is running it, so the sentence
|
||||||
|
describing that behavior can be copied byte-for-byte into every checkout without becoming
|
||||||
|
wrong anywhere.
|
||||||
|
|
||||||
|
The instance-owned files describe a choice: which language pages are written in, what tone the
|
||||||
|
agent takes, who the operator is, which git remote is authoritative, which MCP servers are
|
||||||
|
reachable. None of that follows from the tool's mechanics - two instances of the identical
|
||||||
|
stack can answer all of these differently and both be correct. [AGENTS.md § Personalization](../AGENTS.md#personalization)
|
||||||
|
frames the split the same way for `kb/CONTRACT.md` versus `kb/CONVENTIONS.md`: "the split is by
|
||||||
|
who may change the sentence, not by what it is about." A rule about page structure could in
|
||||||
|
principle have been written per-instance too, but then every instance answering "not German" to
|
||||||
|
setup would be hand-editing a file the stack also ships, and the next `dist export` merge would
|
||||||
|
hand the instance's own file back to it, discarding the customization.
|
||||||
|
|
||||||
|
## Why silent overwrite is the failure being designed against
|
||||||
|
|
||||||
|
A stack update is meant to be a routine, low-risk operation: pull the latest release, get
|
||||||
|
whatever fixes and features shipped since the last one. That only stays low-risk if the update
|
||||||
|
knows which files it's allowed to touch. If `USER.md` or `kb/CONVENTIONS.md` were treated the
|
||||||
|
same as `AGENTS.md` - shipped and periodically re-copied - an upgrade would quietly replace a
|
||||||
|
description of *this* operator, in *this* language, with whatever placeholder or default the
|
||||||
|
stack maintainers wrote. The damage wouldn't be loud: nothing crashes, the files still parse,
|
||||||
|
the agent just starts acting on the wrong premises until someone notices the voice or the
|
||||||
|
language changed.
|
||||||
|
|
||||||
|
Keeping the boundary at the file level, rather than trying to merge changes within a shared
|
||||||
|
file, means an upgrade never has to guess which lines are "stack" and which are "instance" -
|
||||||
|
the file itself already answers that.
|
||||||
|
|
||||||
|
## Why the boundary is a predicate rather than a list
|
||||||
|
|
||||||
|
For a while the boundary was written down as a list of paths - once in `dist_cmd.py`, once in
|
||||||
|
the merge procedure a private instance was told to run by hand, and once in the check that
|
||||||
|
procedure ended with. Three copies of one fact, which is the shape [AGENTS.md](../AGENTS.md)
|
||||||
|
invariant 8 exists to forbid, and they drifted exactly as predicted: the hand-run procedure was
|
||||||
|
still naming three paths after the collection contracts had moved to the instance's side of the
|
||||||
|
line, so it discarded upstream changes to files it had never heard of, while its own final check
|
||||||
|
excluded the same three paths and therefore reported success.
|
||||||
|
|
||||||
|
`chemenu/ownership.py` replaced the lists with one question - is this path, under a content
|
||||||
|
stage, the stack's or the instance's? - answered by shape rather than by enumeration:
|
||||||
|
`<stage>/CONTRACT.md`, and anything ending `.template`. Both consumers ask it, so `dist export`
|
||||||
|
and `wikitool upstream merge` cannot disagree, and a machinery file added under a content stage
|
||||||
|
tomorrow is recognised by both without either being edited. The deeper point is not the
|
||||||
|
deduplication: a list has to be maintained by whoever remembers it exists, and the failure mode
|
||||||
|
when nobody does is silence, because a path the list has never heard of simply looks like
|
||||||
|
content.
|
||||||
|
|
||||||
|
## Why a `.template`, not just an absent file
|
||||||
|
|
||||||
|
The mechanism for instance-owned content is a `.template` file the distribution ships instead
|
||||||
|
of the real one - `USER.md.template`, `SOUL.md.template`, `kb/CONVENTIONS.md.template`,
|
||||||
|
`ENVIRONMENT.md.template`. An alternative would have been to ship nothing at all and let a
|
||||||
|
brand-new instance start from a blank page. The template exists because a blank page doesn't
|
||||||
|
tell [instructions/setup-instance.md](../instructions/setup-instance.md) what shape the answer
|
||||||
|
should take, and it gives nothing for a validator to check afterward.
|
||||||
|
|
||||||
|
A template carries a placeholder value - a sentinel - in the fields that need a real answer.
|
||||||
|
Setup interviews the operator and replaces the sentinel with what they actually said. That
|
||||||
|
gives `doctor` a mechanical way to tell "personalized" from "not yet": a file that still
|
||||||
|
contains the sentinel hasn't been through setup, regardless of whether the file exists. That's
|
||||||
|
also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent - see
|
||||||
|
[AGENTS.md § Environment](../AGENTS.md#environment) - while a missing or unfilled
|
||||||
|
`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md` is a harder failure: `ENVIRONMENT.md` describes one
|
||||||
|
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
||||||
|
state rather than a sign setup was skipped.
|
||||||
|
|
||||||
|
## The consequence in practice
|
||||||
|
|
||||||
|
An upgrade sorts every shipped path into three categories, not two - and the third one only
|
||||||
|
becomes visible once an upgrade is a command rather than a hand-run copy:
|
||||||
|
|
||||||
|
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
|
||||||
|
`tools/`, `types/` and `instructions/` - are the release's to replace.
|
||||||
|
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
|
||||||
|
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
|
||||||
|
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
|
||||||
|
is out of reach by construction rather than by a rule someone has to remember.
|
||||||
|
- **Seeded-once files** - `.wikitool-kb.json`, `CHANGES.md`, `kb/log.md`, `raw/*/.gitkeep` - are
|
||||||
|
written into a *new* instance by `dist export` and belong to the instance from then on. They
|
||||||
|
are the awkward category: they sit in the release stamp's file list like any other shipped
|
||||||
|
file, so an upgrade has to exclude them deliberately (`chemenu.ownership.is_export_stub` and
|
||||||
|
`is_upgrade_preserved`). An upgrade that re-seeded them would reset the record of which
|
||||||
|
migrations ran, or erase the changelog the instance wrote for itself.
|
||||||
|
|
||||||
|
The first category carries a caveat that the word "verbatim" hides. It says who *decides* the
|
||||||
|
content, not that overwriting is always safe: an instance can still have edited a verbatim file
|
||||||
|
- a patched `tools/`, a locally adjusted instruction - and an upgrade assuming otherwise would
|
||||||
|
destroy that silently. Avoiding that assumption is the whole reason `dist export` records a
|
||||||
|
sha256 per shipped file in `.wikitool-release.json`. `wikitool dist upgrade` compares every
|
||||||
|
candidate path against the digest recorded when it was installed, overwrites only what still
|
||||||
|
matches, and refuses rather than overwrite what does not.
|
||||||
|
|
||||||
|
So the practical rule is narrower than "overwrite the verbatim files, leave the rest alone":
|
||||||
|
overwrite the verbatim files *this instance has not touched*, never write the other two
|
||||||
|
categories, and make a locally changed file a decision someone takes deliberately instead of
|
||||||
|
one an upgrade takes for them. The template-sourced files were filled in once, by a person, for
|
||||||
|
a reason, and nothing about a newer release of the stack's mechanics gives it standing to
|
||||||
|
override that.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Why the pipeline has four stages
|
||||||
|
|
||||||
|
Chemenu could, in principle, be one directory: drop a file in, ask a question, get an answer
|
||||||
|
computed fresh each time. It isn't built that way. The pipeline in
|
||||||
|
[AGENTS.md](../AGENTS.md#routing) - `raw/` -> `[types/ + tools/]` -> `kb/` -> `reports/`, with
|
||||||
|
`work/` alongside rather than inside it - separates *material* from *meaning* from
|
||||||
|
*byproduct*, and each seam exists because collapsing it costs something specific.
|
||||||
|
|
||||||
|
## Why raw material stays untouched
|
||||||
|
|
||||||
|
[raw/CONTRACT.md](../raw/CONTRACT.md) keeps a source exactly as it arrived. The reasoning is
|
||||||
|
simple once stated: the moment someone "cleans up" or reformats a source on the way in, the
|
||||||
|
thing later claims get checked against is no longer the thing that was actually said. An
|
||||||
|
immutable `raw/` means a citation always resolves to the original, not to somebody's tidied
|
||||||
|
memory of it. It also draws a trust boundary in one place instead of scattering it - everything
|
||||||
|
past `raw/` can be treated as reviewed, because nothing upstream of it silently already was.
|
||||||
|
|
||||||
|
## Why extraction happens once, through a schema
|
||||||
|
|
||||||
|
[types/type-spec.md](../types/type-spec.md) is what stands between a raw file and a `kb/` page:
|
||||||
|
a type-spec defines what a conforming instance of a page looks like, and the compiler
|
||||||
|
(`tools/wikitool`) applies it. The alternative - every query re-reading and re-interpreting the
|
||||||
|
source on demand - would mean paying the cost of understanding the material every single time,
|
||||||
|
and getting a slightly different answer each time depending on how the question was phrased.
|
||||||
|
Extracting once, against a fixed schema, turns "re-read and re-guess" into "look up what was
|
||||||
|
already compiled." That is the "never re-derive, always compile" principle from
|
||||||
|
[AGENTS.md](../AGENTS.md): understanding a source is expensive and worth doing exactly once,
|
||||||
|
after which it becomes a cheap, stable lookup.
|
||||||
|
|
||||||
|
## Why a `kb/` page has to stand on its own
|
||||||
|
|
||||||
|
[kb/CONTRACT.md](../kb/CONTRACT.md) sets the bar for the compiled layer: a page should answer a
|
||||||
|
future question without sending the reader back to the source it came from. That's the payoff
|
||||||
|
of compiling in the first place - if every answer still bottomed out in "go re-read the raw
|
||||||
|
file," the `kb/` layer would just be a pointer with extra steps, and the cost of extraction
|
||||||
|
would have bought nothing. A page that stands alone is what makes the corpus fast and
|
||||||
|
consistent to query: the work of understanding is already sitting there, done.
|
||||||
|
|
||||||
|
## Why `reports/` doesn't need to be maintained
|
||||||
|
|
||||||
|
[reports/CONTRACT.md](../reports/CONTRACT.md) treats most of what lands in `reports/` -
|
||||||
|
lint output, telemetry traces - as disposable. The structural content of a lint report can be
|
||||||
|
recomputed from the tree at any commit, so keeping an old copy around would just be a second
|
||||||
|
version of something the tool can already answer on demand, and a second copy is exactly the
|
||||||
|
kind of thing that quietly goes stale. Treating it as derived output rather than a fourth thing
|
||||||
|
to maintain means there is nothing there to fall out of sync - regenerating it is cheaper than
|
||||||
|
reconciling it. The one part that genuinely can't be recomputed - the judgment a pass produced -
|
||||||
|
is carried out into `kb/` or `kb/log.md` before the report itself is discarded, which is the
|
||||||
|
distinction between what's recomputable and what isn't.
|
||||||
|
|
||||||
|
## Where `work/` fits
|
||||||
|
|
||||||
|
[work/CONTRACT.md](../work/CONTRACT.md) describes a workshop, not a fifth pipeline stage: a
|
||||||
|
place for the notes, extracts and open decisions of a task that spans more than one session, on
|
||||||
|
its way toward becoming a `kb/` page. It sits beside the raw -> kb -> reports flow rather than
|
||||||
|
inside it - closer in spirit to a desk than to a conveyor belt.
|
||||||
|
|
||||||
|
## The shape this produces
|
||||||
|
|
||||||
|
Four stages, each answering a different question: `raw/` - what was actually said; `types/` +
|
||||||
|
`tools/` - how to turn that into structured understanding; `kb/` - what is now known;
|
||||||
|
`reports/` - what a pass over the corpus noticed in passing. Keeping them separate is what lets
|
||||||
|
each one be trusted for what it is, instead of every layer having to double as all four at
|
||||||
|
once.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Why the stack version splits compatibility from migration
|
||||||
|
|
||||||
|
A stack version number looks like it answers one question. It actually answers two, and the two
|
||||||
|
are independent of each other.
|
||||||
|
|
||||||
|
## Two questions, not one
|
||||||
|
|
||||||
|
The first question is whether the new version is a drop-in replacement for the old one - whether
|
||||||
|
an existing instance can install it, and can also go back, without anyone doing hand-work. That
|
||||||
|
is what a version number *is*: a promise. The second question is whether the existing corpus in
|
||||||
|
`kb/` needs to change shape to keep working under the new version. These sound like the same
|
||||||
|
question, because most of the time a change that breaks compatibility also happens to touch
|
||||||
|
content, and most of the time a change that leaves content untouched also happens to be
|
||||||
|
compatible. The correlation is real; it just is not a law. `instructions/dev/version-parts.md`
|
||||||
|
carries the actual test for telling them apart and the steps that follow from it - this page is
|
||||||
|
about why the split exists at all.
|
||||||
|
|
||||||
|
## Why "kb/ untouched" is not proof of anything
|
||||||
|
|
||||||
|
The tempting shortcut is: if no page in `kb/` had to change, the bump can't be that serious. This
|
||||||
|
is exactly backwards for a class of changes that live entirely outside the corpus - a renamed
|
||||||
|
release artefact, a Python import path, an environment variable, the URL an instance's own
|
||||||
|
updater points at. None of those touch a single page. All of them can strand an existing
|
||||||
|
instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack
|
||||||
|
that looks at itself; the compatibility question is about everything an instance depends on to
|
||||||
|
keep functioning, most of which the corpus never sees.
|
||||||
|
|
||||||
|
## Reading compatibility off the leftmost non-zero component
|
||||||
|
|
||||||
|
Semantic versioning gives every component a job, but only one of them is where an existing
|
||||||
|
instance's tooling actually looks to decide "is this safe." On a `2.x` stack that is MAJOR; on a
|
||||||
|
still-pre-1.0 `0.x` stack, by the same convention, it's MINOR - the leftmost slot that isn't
|
||||||
|
pinned to zero is the one an automated updater treats as the compatibility boundary. Bump
|
||||||
|
anything to its left, or bump that slot itself, and the promise changes. Everything to the right
|
||||||
|
of it can move as freely as the project likes without touching that promise. This is why the
|
||||||
|
question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about
|
||||||
|
how big the change is.
|
||||||
|
|
||||||
|
## Downgrade is half the promise
|
||||||
|
|
||||||
|
It's natural to test compatibility by only asking "does the upgrade work." The other half -
|
||||||
|
"can an instance that upgraded put the old version back and land where it started" - carries
|
||||||
|
equal weight, and it's the half that's easy to forget because forward motion is what everyone is
|
||||||
|
testing for anyway. A state file the old version can no longer parse, a generated index in a new
|
||||||
|
shape, a stamp file that got renamed: none of these have to break the upgrade to break the
|
||||||
|
downgrade. An instance that can go forward but not back has already lost the property a
|
||||||
|
compatible version number is supposed to guarantee.
|
||||||
|
|
||||||
|
## A promise made to a machine, not only to a person
|
||||||
|
|
||||||
|
A human reading a changelog can absorb "this technically isn't compatible but it's fine, just
|
||||||
|
update those two things by hand." An instance's own update mechanism cannot. It reads a version
|
||||||
|
number, decides whether to pull the new release, and has no channel for nuance - which is exactly
|
||||||
|
why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the
|
||||||
|
new version moves where updates come from, the very channel that would have told an instance to
|
||||||
|
adjust is the channel that just broke. The version number isn't documentation aimed at a reader;
|
||||||
|
it's an input consumed by code that has no other way to ask.
|
||||||
|
|
||||||
|
## The 2.0.0 story
|
||||||
|
|
||||||
|
This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo,
|
||||||
|
the release artefact, and the Python package - and left every page in `kb/` untouched. The first
|
||||||
|
instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That
|
||||||
|
reasoning was correct on its own terms and answered the wrong question. Three things broke
|
||||||
|
underneath it: every existing instance's `update_url` pointed at a repo path that no longer
|
||||||
|
existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact
|
||||||
|
name changed, breaking every download script and pin against it; and the import name changed,
|
||||||
|
breaking anything importing the package from outside the shipped tree. The corpus had nothing to
|
||||||
|
say about any of this, because none of it lived in the corpus.
|
||||||
|
|
||||||
|
What caught the mistake was a person looking at the diff and asking whether it really was a
|
||||||
|
drop-in replacement, not a validator. No check in `docs verify` or anywhere else confirms that a
|
||||||
|
version part was chosen correctly - it only confirms that a boundary-crossing bump documents
|
||||||
|
what it breaks. The 2.0.0 entry in `CHANGES.md` carries the corrected reasoning in full, and the
|
||||||
|
version bump that shipped it was `--major --no-migration`: boundary-crossing and untouched
|
||||||
|
corpus, at the same time, which is precisely the combination the two-question split exists to
|
||||||
|
make visible.
|
||||||
|
|
||||||
|
## Why a number is only spent by a release
|
||||||
|
|
||||||
|
Everything above is about what a version number *promises*. A separate question turned out to
|
||||||
|
matter just as much in practice: how many numbers get handed out along the way to making one
|
||||||
|
release. For a while the answer was "one per bump," and that turned out to be the wrong grain
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
Two mechanisms decide when a number gets minted, and they answer different questions. CI's
|
||||||
|
version gate asks a *commit*-level one: has this tree changed since the last push, and if so
|
||||||
|
has `VERSION` moved with it. A release asks something else entirely: is this a state worth
|
||||||
|
handing to someone, under a number they will pin against. Tying the second to the first - every
|
||||||
|
`VERSION` move firing the release workflow - answers the gate correctly and the release question
|
||||||
|
by accident, because it treats every bump as if it were about to ship when most bumps are steps
|
||||||
|
toward a release that has not happened yet.
|
||||||
|
|
||||||
|
The failure mode is not phantom numbers; every one of those releases was real, tagged and
|
||||||
|
downloadable. It is that "real" stopped meaning anything. On 2026-09-03 this repository cut four
|
||||||
|
releases in six hours - `4.3.0` through `4.3.3` - for one continuous arc of work, two of them for
|
||||||
|
prose changes alone. Someone tracking the feed saw four upgrades and had no way to tell which, if
|
||||||
|
any, was a moment worth stopping for. A release is a promise addressed to a consumer, and a
|
||||||
|
promise made four times an afternoon is not a smaller promise, it is a less legible one.
|
||||||
|
|
||||||
|
The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still
|
||||||
|
gets that. It is to stop treating every movement as a number worth publishing. Between two
|
||||||
|
releases the stack now carries one running candidate, escalating through `-beta.N` as bumps
|
||||||
|
accumulate, and only `version release` spends the number for real by fixing it and closing its
|
||||||
|
changelog entry. A number is proposed by a bump and spent by a release; conflating the two was
|
||||||
|
the actual defect, not the arithmetic of any single bump.
|
||||||
|
|
||||||
|
This is also why a candidate never gets to a distributed instance. The promise a released version
|
||||||
|
makes - "install this, and it is exactly what its number says" - has no equivalent for something
|
||||||
|
still being decided during a single dev checkout's session. `release.yml`'s only job with respect
|
||||||
|
to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but
|
||||||
|
because there is nothing yet to promise.
|
||||||
|
|
||||||
|
## Where the procedure lives
|
||||||
|
|
||||||
|
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||||
|
steps for a boundary-crossing bump - the `--breaking` line, the migration document or
|
||||||
|
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
||||||
|
place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Why gates are code
|
||||||
|
|
||||||
|
Chemenu has three hard limits - the Mass-Update Gate, the Publish-Remote Gate, and the
|
||||||
|
Iteration Budget Gate - and all three live inside `tools/wikitool`, not in a paragraph of
|
||||||
|
instructions an agent reads and follows. The rules themselves, and what to do when one trips,
|
||||||
|
are in [AGENTS.md § Gates](../AGENTS.md#gates) and [instructions/gates.md](../instructions/gates.md).
|
||||||
|
This page is only about the design choice underneath them: why code, and why these three
|
||||||
|
mechanisms in particular.
|
||||||
|
|
||||||
|
## A suggestion an agent can talk itself past
|
||||||
|
|
||||||
|
An instruction like "don't publish too much at once" or "don't loop forever" lives in the same
|
||||||
|
place as every other piece of guidance a session is holding - alongside the task, the user's
|
||||||
|
last message, and whatever context made the moment feel urgent. Under pressure, or with a
|
||||||
|
plausible-sounding reason ("this batch is different, it's mechanical"), that guidance can be
|
||||||
|
reasoned around without anyone deciding to break a rule. Nothing enforces it; it just competes
|
||||||
|
for attention with everything else in the context window, and sometimes loses.
|
||||||
|
|
||||||
|
A check compiled into the tool doesn't have that problem, because it isn't part of the
|
||||||
|
conversation at all. It runs before the command dispatches, regardless of how convincing the
|
||||||
|
case for skipping it seemed a moment earlier. The difference isn't that code is smarter than a
|
||||||
|
well-written instruction - it's that code doesn't get talked into anything.
|
||||||
|
|
||||||
|
## Why three different mechanisms, not one
|
||||||
|
|
||||||
|
The three gates ask three different questions, and each one's shape follows from what kind of
|
||||||
|
question it is.
|
||||||
|
|
||||||
|
The Mass-Update Gate asks *is this change too large to publish unreviewed* - a judgment that
|
||||||
|
varies changeset by changeset, so it clears with a `--confirm` token tied to the specific
|
||||||
|
output the user just read. Approval is scoped to that one publish.
|
||||||
|
|
||||||
|
The Publish-Remote Gate asks something underneath that: *is this even the right repository*.
|
||||||
|
That's not a per-push judgment, it's a standing property of the checkout - true or false for
|
||||||
|
every publish that checkout will ever attempt, not just this one. A confirm token would let an
|
||||||
|
agent clear it once and then treat the answer as settled, which is exactly backwards for a
|
||||||
|
question whose answer shouldn't move at all mid-session. The only way past it is the user
|
||||||
|
editing `.wikitool-remotes.json` directly, outside the gate's own flow.
|
||||||
|
|
||||||
|
The Iteration Budget Gate asks a third kind of question - not "is this instance correct" but
|
||||||
|
"has this session stopped making progress." That's read from the shape of the call history
|
||||||
|
itself (call count, repeated identical calls), not from anything about the content of any one
|
||||||
|
call.
|
||||||
|
|
||||||
|
## Numbers that come from measurement, not intuition
|
||||||
|
|
||||||
|
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
||||||
|
general rule of thumb, until four real ingest runs measured 24, 26, 29 and 30 calls apiece -
|
||||||
|
every one of them an ordinary workflow doing nothing wrong, and every one of them at or past
|
||||||
|
where the old ceiling would have refused it. A limit that the normal case keeps tripping stops
|
||||||
|
functioning as a limit; it becomes background noise a session learns to route `--override-budget`
|
||||||
|
around as a matter of course, and the whole point of a hard-coded check is that it isn't supposed
|
||||||
|
to feel routine.
|
||||||
|
|
||||||
|
That's the deeper reason these numbers live in a tool rather than in prose: prose is read once
|
||||||
|
and remembered loosely, but a threshold enforced every call is tested by every call, and a
|
||||||
|
threshold that fails its own test gets noticed and re-measured rather than quietly ignored.
|
||||||
|
|
||||||
|
The suite's coverage floor is the same argument run forwards instead of backwards. The ceiling
|
||||||
|
above was wrong first and measured afterwards; the floor was withheld on purpose until the number
|
||||||
|
existed - measured, then watched across 38 runs while the code grew by a quarter, and only then
|
||||||
|
written down as 85 against an observed 87.0%. The two points of daylight are the same
|
||||||
|
consideration as the ceiling's headroom: a limit the ordinary case keeps tripping stops being a
|
||||||
|
limit. A coverage floor set at the measured number goes red on the next thin command wrapper,
|
||||||
|
which is not a regression, and a threshold that goes red for a non-reason gets lowered rather
|
||||||
|
than earned - the failure mode above, reached from the other direction.
|
||||||
@@ -6,10 +6,24 @@ description: Which Claude model and effort level to run a Claude Code session, a
|
|||||||
|
|
||||||
# Pick the Claude model and effort level for the task at hand
|
# Pick the Claude model and effort level for the task at hand
|
||||||
|
|
||||||
Scale the model and effort to how much judgment the task actually needs. Running everything at
|
Scale the model and effort to **what catches a mistake in this part of the work** - not to how
|
||||||
the most capable model and highest effort is safe but wasteful: the gates in [gates.md](gates.md)
|
important the task feels, and not to its name. Running everything at the most capable model and
|
||||||
are enforced in code, not by model judgment, so a weaker model cannot bypass them - it can only
|
highest effort is safe but wasteful: the gates in [gates.md](gates.md) are enforced in code, not
|
||||||
do a worse job of the calls the gates don't cover.
|
by model judgment, so a weaker model cannot bypass them - it can only do a worse job of the calls
|
||||||
|
the gates don't cover.
|
||||||
|
|
||||||
|
That last clause is the whole rule, turned into a test. Where a check lives in code - `pytest`,
|
||||||
|
`docs verify`, `instructions verify`, CI, the gates - a weaker model's mistake surfaces and costs
|
||||||
|
one more round. Where the only enforcement is a session reading prose, the same mistake does not
|
||||||
|
surface at all: it ships, and it stays until someone happens to notice. The two are not the same
|
||||||
|
risk, and they should not get the same model. This is the argument
|
||||||
|
[docs/why-gates-are-code.md](../docs/why-gates-are-code.md) makes about gates, applied to who is
|
||||||
|
holding the keyboard.
|
||||||
|
|
||||||
|
Both directions cost something, which is why the axis matters rather than a blanket answer:
|
||||||
|
over-provisioning is a standing cost paid every session, while under-provisioning in an unchecked
|
||||||
|
phase is a silent error with a long tail. A corrective session, its bump, its CI runs and its
|
||||||
|
release together cost more compute than the model difference they were saving.
|
||||||
|
|
||||||
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
||||||
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
||||||
@@ -28,17 +42,45 @@ to *make*, not a setting to apply.
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Recommend the session's model and effort by the skill in use**, when asked or when the
|
1. **Recommend the session's model and effort by what catches a mistake in the phase it is in**,
|
||||||
mismatch is worth one sentence. Say it once and continue working either way - a session that
|
when asked or when the mismatch is worth one sentence. Say it once and continue working either
|
||||||
argues about its own model instead of doing the task has already cost more than the model
|
way - a session that argues about its own model instead of doing the task has already cost
|
||||||
difference:
|
more than the model difference:
|
||||||
|
|
||||||
| Skill / task | Model | Effort |
|
| Phase / task | What catches a mistake here | Model | Effort |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `wiki-status`, simple `wiki-query` lookups | Sonnet | default |
|
| `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
|
||||||
| `wiki-lint` | Sonnet | default |
|
| `wiki-lint` | `lint` itself is the check | Sonnet | default |
|
||||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | Sonnet | high |
|
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly - the judgment about a claim is not covered | Sonnet | high |
|
||||||
| Stack development: `tools/`, `types/`, `instructions/` as code | Opus | high |
|
| Stack dev: design, the version part, a boundary-crossing judgment | nothing - `docs verify` checks that a crossing documents itself, never that the part was right | Opus | high |
|
||||||
|
| Stack dev: code, tests, mechanical doc sync (command tables, contract rows) | `pytest`, `docs verify`, `instructions verify`, CI | Sonnet | high |
|
||||||
|
| Stack dev: closing an issue, `docs/` staleness, changelog prose | nothing, by construction - see below | Opus | high |
|
||||||
|
|
||||||
|
**Stack development is not one row**, which is the point of splitting it. The middle phase is
|
||||||
|
where the tokens are and where the checks are, so it is the phase worth running cheaper. The
|
||||||
|
two around it have no mechanical guard at all - a `docs/` page carries no normative sentence,
|
||||||
|
so there is nothing for `docs verify` to check ([AGENTS.md](../AGENTS.md) § File naming), and
|
||||||
|
the same holds for whatever tracker an instance keeps its open work in, which `wikitool`
|
||||||
|
deliberately knows nothing about. Those two phases are short - minutes, not hours - so keeping
|
||||||
|
them on the stronger model is cheap, and it protects the only work in the session that fails
|
||||||
|
silently.
|
||||||
|
|
||||||
|
**Effort is the cheaper lever than the model.** Reach for it first: `medium` deliberately does
|
||||||
|
not appear in this table for stack work, because multi-file consistency is exactly what a
|
||||||
|
reduced effort level gives up. Sonnet at `high` is the floor for anything touching more than
|
||||||
|
one file or a contract; `default` is for a single-file mechanical edit with a test behind it.
|
||||||
|
|
||||||
|
**A session cannot switch its own model**, so these rows only become real if someone offers the
|
||||||
|
switch at the moment the phase changes - once, without arguing about it, and never as a reason
|
||||||
|
to stop work that is already underway.
|
||||||
|
<!-- dist:strip-start -->
|
||||||
|
In this repo those moments are named, one per skill rather than both in one: `stack-dev`'s own
|
||||||
|
step 3 breaks for the first (design settled, work turns mechanical), and `stack-dev` itself
|
||||||
|
ends at the publish rather than asking the same session to break out of its own momentum a
|
||||||
|
second time. The second switch lives at the opening of `stack-close`, the skill `stack-dev`
|
||||||
|
hands off to once the publish succeeds (the unchecked tail begins) - a session has to invoke
|
||||||
|
it to reach that step at all, which is the point: nothing left to skip past mid-flow.
|
||||||
|
<!-- dist:strip-end -->
|
||||||
|
|
||||||
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
||||||
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
||||||
@@ -66,8 +108,14 @@ to *make*, not a setting to apply.
|
|||||||
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
||||||
supervising it.
|
supervising it.
|
||||||
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
||||||
the highest effort. Under-provisioning costs one worse answer in one session; reflexively
|
the highest effort. Under-provisioning *where a check exists* costs one worse answer in one
|
||||||
over-provisioning is a standing cost paid every session.
|
session; reflexively over-provisioning is a standing cost paid every session.
|
||||||
|
- **Unsure whether the phase is checked?** Treat it as unchecked. The asymmetry is not symmetric:
|
||||||
|
a needless Opus phase costs money once, an unchecked Sonnet phase can ship something nobody
|
||||||
|
looks at again.
|
||||||
|
- **Mid-session and the phase changed, but nobody switched?** Do the work anyway - never block a
|
||||||
|
publish or an issue close on a model the session cannot change itself. Say which phase ran on
|
||||||
|
which model in the handover, so the gap is visible rather than silent.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
type: types/instruction.md
|
||||||
|
name: corpus-policy
|
||||||
|
description: What "curated enough" means for kb/ when it is demo and testbed at once, the measurable floors that define it, and what a reactive fix to the corpus may and may not do.
|
||||||
|
---
|
||||||
|
# Keep kb/ curated enough to develop against, without a second corpus
|
||||||
|
|
||||||
|
This instance runs one `kb/` for two purposes at once: a public demo and the testbed this stack
|
||||||
|
is developed against. There is deliberately no fixture corpus, no `--with-demo` export, and no
|
||||||
|
second repository - see Gitea #28. The corpus's size and shape are set by what targeted
|
||||||
|
development needs, not by a synthetic fixture size or a demo aesthetic.
|
||||||
|
|
||||||
|
## When to run
|
||||||
|
|
||||||
|
- Before judging whether the corpus can exercise a change under development - ranking, index
|
||||||
|
scaling, orphan detection, a new label, a new type-spec.
|
||||||
|
- Before a reactive fix touches `kb/` content rather than the failing code - the floors below
|
||||||
|
are what decides whether the fix may proceed as-is.
|
||||||
|
- Picking up Gitea #28 or #30, or any issue that references this file.
|
||||||
|
|
||||||
|
## The floors
|
||||||
|
|
||||||
|
Each is mechanically checkable with an existing `wikitool` command; none needs new tool code.
|
||||||
|
A floor exists to keep some class of bug observable, not to describe an aesthetic target - so
|
||||||
|
when a session is about to make one of these numbers *worse*, that is the signal to stop and
|
||||||
|
think, not a number to defend for its own sake.
|
||||||
|
|
||||||
|
| Floor | Check | Why this number |
|
||||||
|
|---|---|---|
|
||||||
|
| Every page type has ≥1 page | `wikitool search --field type=types/<t>.md` | A type with zero pages means its schema, its collection contract and its lint rules are unexercised |
|
||||||
|
| Every declared subtype has ≥1 page | `wikitool search --field <x>_type=<v>` | Same reasoning, one level down - `entity_type`, `concept_type`, `source_type` |
|
||||||
|
| ≥5 pages corpus-wide with ≥3 `sources:` entries | one-off script, see below | Provenance fan-in - multiple sources backing one claim - is a real case only a handful of pages exercise; fewer than 5 and a provenance-index bug can hide |
|
||||||
|
| Orphan pages (no inbound link) between 1 and 10 | `wikitool lint` | Zero orphans makes orphan detection itself unobservable; more than 10 means the corpus stopped being curated |
|
||||||
|
| Average outbound wikilinks per page ≥4 | one-off script, see below | Below this, ranking and graph-traversal work has too little structure to exercise |
|
||||||
|
|
||||||
|
A floor is a lower bound only. There is no upper bound on page count or on any of these numbers
|
||||||
|
except the orphan ceiling above - a corpus that outgrows these floors through real ingests is
|
||||||
|
not a problem this file cares about.
|
||||||
|
|
||||||
|
**Measured 2026-09-03** (see Gitea #28): 181 pages, 14/14 types and subtypes covered, 12 pages
|
||||||
|
with ≥3 sources, 3 orphans, 6.2 average outbound links. All floors held without any manufactured
|
||||||
|
content - the corpus was already big enough when the question was asked.
|
||||||
|
|
||||||
|
A type or subtype sitting at exactly the floor - one page - shows no set-level bugs, only that
|
||||||
|
the type is *reachable*. That is a soft target for the next `wiki-ingest` that happens to
|
||||||
|
produce a matching page, never a reason to write one: filing an unsourced page to clear a floor
|
||||||
|
is exactly what AGENTS.md invariant 3 forbids, floor or no floor. The same holds for an
|
||||||
|
authorised link label with zero live uses (`wikitool xref` reports these) - fill it when a real
|
||||||
|
edge calls for it, never manufacture one to exercise the label.
|
||||||
|
|
||||||
|
To check the two floors without a dedicated command, walk `kb/**/*.md` (excluding
|
||||||
|
`INDEX.md`/`COLLECTION.md`/`CONTRACT.md`/`CONVENTIONS.md`), parse frontmatter, and: count pages
|
||||||
|
whose `related:` array (resolved against page titles) has ≥3 entries for outbound density; count
|
||||||
|
`sources:` array length ≥3 for the provenance floor. `wikitool search` and `wikitool lint`
|
||||||
|
cover everything else in the table.
|
||||||
|
|
||||||
|
## What a reactive fix may do to kb/ content
|
||||||
|
|
||||||
|
Three tiers, by how much of the corpus a change touches:
|
||||||
|
|
||||||
|
1. **Pointwise - always allowed.** Creating, updating, renaming or deleting a single page
|
||||||
|
through the normal tools (`new`, `touch`, the page-lifecycle procedure), below the
|
||||||
|
Mass-Update Gate's threshold. This is ordinary work and needs no special permission.
|
||||||
|
2. **Corpus-wide - planned only, never reactive.** A migration, a vocabulary sweep, a bulk
|
||||||
|
`touch` across many pages. This needs its own issue and, per `work/CONTRACT.md`, a `work/`
|
||||||
|
run - never a same-session reaction to whatever the session was originally doing. If a
|
||||||
|
session hits the Mass-Update Gate (exit 42, see `instructions/gates.md`) while working on
|
||||||
|
something else, it does not fetch the `--confirm` token to push through: it stops, opens an
|
||||||
|
issue for the corpus-wide change, and finishes the original task without it.
|
||||||
|
3. **Reactive - never allowed.** Deleting or reshaping a page to make a failing test pass;
|
||||||
|
restructuring corpus content to route around a tool bug (AGENTS.md invariant 7); using
|
||||||
|
`kb/` as a scratch surface for a tool experiment. If a stack change under development needs a
|
||||||
|
corpus shape that does not exist, build it as a pytest fixture (see the next section) -
|
||||||
|
never manufacture it in `kb/`.
|
||||||
|
|
||||||
|
## Relationship to the test fixtures
|
||||||
|
|
||||||
|
`tools/chemenu/tests/conftest.py`'s `kb_dir`/`raw_dir` fixtures and `test_pipeline_l0.py` cover
|
||||||
|
the **small, isolated** case: a handful of pages, built fresh per test, hermetic. `kb/` covers
|
||||||
|
the **large, connected** case: 181+ pages, grown link density, real provenance history that no
|
||||||
|
per-test fixture reconstructs economically. The cut: if a `tmp_path` tree can reproduce what the
|
||||||
|
test needs, it belongs in a fixture; if the test needs density or scale that only a grown corpus
|
||||||
|
has, it belongs against `kb/`. Neither absorbs the other's job - see
|
||||||
|
[testing-conventions.md](testing-conventions.md).
|
||||||
|
|
||||||
|
## Decision points
|
||||||
|
|
||||||
|
- **A floor would be violated by an in-progress change - is that a blocker?** Only for the
|
||||||
|
orphan ceiling and the type/subtype floors, since those two can go to zero. The density and
|
||||||
|
provenance floors move gradually with ordinary ingests and are not gating on any single
|
||||||
|
session.
|
||||||
|
- **Corpus is "too small" for a feature under development?** That is not this file's problem to
|
||||||
|
solve by adding pages - see tier 3 above. Either the feature waits for a real ingest to supply
|
||||||
|
the shape, or it gets a pytest fixture.
|
||||||
@@ -28,8 +28,14 @@ issues at that URL, which is exactly why `dist export` excludes
|
|||||||
an assumption nobody has checked, a decision that needs the user.
|
an assumption nobody has checked, a decision that needs the user.
|
||||||
- Picking an issue up: before doing anything else, read the body as the current
|
- Picking an issue up: before doing anything else, read the body as the current
|
||||||
spec, and re-label it if the ground has moved since.
|
spec, and re-label it if the ground has moved since.
|
||||||
|
- **While working on one:** the body is updated as the state moves, not at the
|
||||||
|
end (step 2). A session that is interrupted leaves the body as its handover.
|
||||||
- Prioritising: deciding what to pick up next, or re-labelling after the ground
|
- Prioritising: deciding what to pick up next, or re-labelling after the ground
|
||||||
moved.
|
moved.
|
||||||
|
- Closing one: the body is rewritten to its final state first, and only then
|
||||||
|
closed (step 7).
|
||||||
|
- A rename or move ships: sweep the open issues for text that assumed the old
|
||||||
|
name or path (§ Renames and other decay in the tracker).
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
@@ -38,23 +44,93 @@ issues at that URL, which is exactly why `dist export` excludes
|
|||||||
specific files or commands involved. An issue that only makes sense to
|
specific files or commands involved. An issue that only makes sense to
|
||||||
whoever wrote it is a note, and notes were the problem.
|
whoever wrote it is a note, and notes were the problem.
|
||||||
|
|
||||||
2. **Treat the body as the current truth, not as a historical first post.**
|
**Destructive steps carry the invariant they must not violate.** A body
|
||||||
Work on one issue spans several sessions, often weeks apart, and the body is
|
that prescribes a mechanism gets built as prescribed - including its
|
||||||
the only thing that connects them: a session opening the issue must be able
|
bugs. Where a step deletes, overwrites, resets or moves, name the
|
||||||
to reconstruct what is decided and what is still open from the body alone,
|
property that must still hold afterwards, not only the command that gets
|
||||||
without a human re-explaining it. So when the state changes, **rewrite the
|
there. "Remove the working directory, then `git checkout HEAD --
|
||||||
body** - do not append to a text that has become wrong. An additively grown
|
<stage>`" is a mechanism; "the content stages must afterwards match
|
||||||
log forces every later reader to reconstruct the current state by filtering
|
`HEAD` exactly, without any untracked or ignored file being touched" is
|
||||||
the whole history.
|
the same instruction plus its test - a build instruction and an
|
||||||
|
acceptance criterion at once, so the defect surfaces while the test is
|
||||||
|
written rather than in review afterwards. #30's `upstream merge` body
|
||||||
|
wrote the mechanism and got exactly that bug: a working-directory removal
|
||||||
|
that took a stage's gitignored, unrecoverable data with it.
|
||||||
|
|
||||||
|
**An acceptance criterion states a checkable property, not an activity.**
|
||||||
|
"Implement X" is done when someone says so; "after `upstream merge`,
|
||||||
|
`reports/` still holds every file it held before" is done when it is
|
||||||
|
true. This is not a ban on imperative steps - a numbered procedure can
|
||||||
|
still produce a correct control flow, and that is its merit - it binds
|
||||||
|
the destructive steps, and every box in the criteria list.
|
||||||
|
|
||||||
|
2. **The body is the working state, not a historical first post - keep it
|
||||||
|
current as you go.** It is this stack's plan file: the same thing a harness's
|
||||||
|
own plan document is, and it is maintained the same way. Not written once,
|
||||||
|
not brought up to date at the end, but **updated whenever something in it
|
||||||
|
stops being true** - a decision made, a criterion met, an approach ruled out,
|
||||||
|
a new constraint found.
|
||||||
|
|
||||||
|
The test is an abort, not a milestone. A session can end at any moment - an
|
||||||
|
interrupt, a context limit, a crash, a human walking away - and whatever the
|
||||||
|
body says at that instant is the entire handover. So the standard is: **at
|
||||||
|
every point, a fresh session must be able to open the body and pick the work
|
||||||
|
up from there**, without a human re-explaining it and without reading back
|
||||||
|
through the comments. If the body would mislead someone who read it right
|
||||||
|
now, it is already out of date, whether or not the work is finished.
|
||||||
|
|
||||||
|
That means updating *during* the work, not only at its end:
|
||||||
|
|
||||||
|
- a decision gets made → the decision and its reasoning replace the question
|
||||||
|
- an acceptance criterion is done → tick it, in the same session that did it
|
||||||
|
- something turns out differently than the issue assumed → the assumption is
|
||||||
|
corrected where it stands, not contradicted three paragraphs later
|
||||||
|
- work is deferred or dropped → say so, with the reason, where the criterion is
|
||||||
|
|
||||||
|
**Rewrite, never append.** Do not add to a text that has become wrong: an
|
||||||
|
additively grown log forces every later reader to reconstruct the current
|
||||||
|
state by filtering the whole history, which is the exact cost the body exists
|
||||||
|
to remove. Comments carry the history (step 3); the body carries the state.
|
||||||
|
|
||||||
Body rewrites and comments are an LLM session's job. A human normally
|
Body rewrites and comments are an LLM session's job. A human normally
|
||||||
touches only labels and metadata directly.
|
touches only labels and metadata directly.
|
||||||
|
|
||||||
3. **Comment a changelog, never a copy.** Every body rewrite gets one short
|
**Reading an issue, the body is the state and comments are history.** A
|
||||||
comment naming only what changed against the previous state - what is new,
|
session picking an issue up reads the body as the spec; comments are read
|
||||||
what is gone, what was corrected. Do not snapshot the old body into a
|
for provenance - why something was decided, what was tried - never as
|
||||||
comment: a full copy per revision forces a human to diff two prose texts,
|
the current instruction. A recommendation in a comment can be older than
|
||||||
which is not a readable history, only another copy.
|
the body's decision and read just as convincingly: on #30 an earlier
|
||||||
|
comment recommended a smaller, `verify`-only command, while the body had
|
||||||
|
since settled on building the full `merge` command. A session trusting
|
||||||
|
the comment would have built the wrong thing, with a plausible
|
||||||
|
justification out of this repo's own tracker.
|
||||||
|
|
||||||
|
**A body that is demonstrably wrong is corrected first, not worked
|
||||||
|
around.** "Body beats comment" is a rule of precedence, not a licence to
|
||||||
|
execute a stale spec. Where a comment or the tree proves a claim in the
|
||||||
|
body false, the body is rewritten before the work starts - the rewrite
|
||||||
|
above is the fix; leaning on the comments as the "real" state is not.
|
||||||
|
#10 is the case: its body claimed coverage had never been measured while
|
||||||
|
three comments carried a percentage, a statement count and a CI run
|
||||||
|
number.
|
||||||
|
|
||||||
|
**Where two comments contradict each other, evidence decides, not
|
||||||
|
recency.** On #10, one comment showed a retrieved artifact with zero
|
||||||
|
items on a finished run - the report was not actually retrievable - and
|
||||||
|
a later comment declared the same criterion met without re-checking. The
|
||||||
|
later comment is not the newer truth, only the unchecked one. Resolve it
|
||||||
|
into the body with the evidence named, or mark the point open.
|
||||||
|
|
||||||
|
3. **Comment a changelog, never a copy.** A body rewrite gets one short comment
|
||||||
|
naming only what changed against the previous state - what is new, what is
|
||||||
|
gone, what was corrected. Do not snapshot the old body into a comment: a full
|
||||||
|
copy per revision forces a human to diff two prose texts, which is not a
|
||||||
|
readable history, only another copy.
|
||||||
|
|
||||||
|
One comment per *session's worth* of change, not per edit. Step 2 asks the
|
||||||
|
body to be kept current continuously, and a comment for every tick would bury
|
||||||
|
the board in noise; the changelog line summarises what that session moved.
|
||||||
|
Trivial upkeep - a typo, a tightened sentence - needs no comment at all.
|
||||||
|
|
||||||
```
|
```
|
||||||
**Changelog:** Decision 2 tightened - `kind/` may now change over an
|
**Changelog:** Decision 2 tightened - `kind/` may now change over an
|
||||||
@@ -127,10 +203,67 @@ issues at that URL, which is exactly why `dist export` excludes
|
|||||||
answered can drop a size and move `kind/decision` to `kind/build`. Silent
|
answered can drop a size and move `kind/decision` to `kind/build`. Silent
|
||||||
re-labelling is how a board stops meaning anything.
|
re-labelling is how a board stops meaning anything.
|
||||||
|
|
||||||
7. **Close with what actually happened**, not with a commit hash alone: which
|
7. **Closing is the last body update, not a comment.** If step 2 was followed
|
||||||
proposals were implemented, which were deliberately left out and why, and
|
the body is already nearly there, and closing only settles what the final
|
||||||
what was verified. The issue is the only place that record survives - a
|
run established. If it was not, closing is where the whole debt comes due -
|
||||||
changelog entry says what changed, not what was decided against.
|
and it comes due at the worst moment, because a closed body is the version
|
||||||
|
everyone reads afterwards and nobody revisits.
|
||||||
|
|
||||||
|
Either way the body reaches its final state *before* the issue closes:
|
||||||
|
proposals that were decided read as decided, a "to decide" section has become
|
||||||
|
the decision with its reasoning, acceptance criteria are ticked or struck with
|
||||||
|
a reason, and what was verified is named. Then close, with the one-line
|
||||||
|
changelog comment step 3 asks for.
|
||||||
|
|
||||||
|
Record what actually happened, not a commit hash alone: which proposals were
|
||||||
|
implemented, which were deliberately left out and why, and what was verified.
|
||||||
|
The issue is the only place that record survives - a changelog entry says
|
||||||
|
what changed, not what was decided against.
|
||||||
|
|
||||||
|
**A closing report in a comment does not satisfy this.** It reads as
|
||||||
|
complete to whoever writes it and leaves a body still phrased as open work:
|
||||||
|
unticked boxes, an undecided decision section, present tense about a defect
|
||||||
|
that no longer exists. #44 closed exactly that way, with a thorough comment
|
||||||
|
above a body that still asked for a decision that had already been made and
|
||||||
|
shipped. Nothing mechanical catches it (see below), which is why it is a step
|
||||||
|
rather than a habit.
|
||||||
|
|
||||||
|
## Renames and other decay in the tracker
|
||||||
|
|
||||||
|
A rename is not finished when the tree is green. Renaming a package, a path,
|
||||||
|
a command, a flag or the repository itself moves text that lives outside the
|
||||||
|
working tree, and the open issues are the largest such text. Nothing catches
|
||||||
|
them - `wikitool` does not know this tracker exists and must not learn (see
|
||||||
|
"What no tool checks" below) - so a pass over the open issues is part of the
|
||||||
|
rename, in the session that did it, not a follow-up someone remembers.
|
||||||
|
|
||||||
|
Distinguish a wayfinder from a piece of evidence: a path meant to point at
|
||||||
|
where something *is* gets pulled through; a path quoted for what was true at
|
||||||
|
a time is left standing and dated. Note per corrected body what was pulled
|
||||||
|
through and when, so the next pass can tell a checked body from one that
|
||||||
|
merely looks right. Closed issues are out of scope - they guide nobody.
|
||||||
|
|
||||||
|
Renames are not the only thing that ages an issue text. A page a body cites
|
||||||
|
can vanish from `kb/` (`wikitool search` against the cited titles is the
|
||||||
|
second pass), and an old body can carry private infrastructure detail into
|
||||||
|
what is now a public tracker - both found in the same issue, both worth the
|
||||||
|
same look.
|
||||||
|
|
||||||
|
## What no tool checks
|
||||||
|
|
||||||
|
`wikitool` does not know this tracker exists, and should not learn. It ships to
|
||||||
|
instances that have no issues at that URL, while this file and the workflow it
|
||||||
|
describes are pruned by `dist export` - a Gitea client inside the shipped tool
|
||||||
|
would be a dev-only dependency carried by every instance, to check a board none
|
||||||
|
of them have. The tracker is reachable only through the `gitea-mcp` server, in a
|
||||||
|
session, by an agent.
|
||||||
|
|
||||||
|
So there is no `docs verify` for the board. Nothing reports a closed issue whose
|
||||||
|
body still reads as open, a body that contradicts its own comments, or an issue
|
||||||
|
missing one of the four mandatory labels. Every one of those is caught by a
|
||||||
|
session following this file, or not at all - which is the argument for the
|
||||||
|
sequence in step 7 being explicit about the order (body first, then close),
|
||||||
|
rather than leaving it to be inferred from step 2.
|
||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
|
|
||||||
@@ -144,7 +277,8 @@ issues at that URL, which is exactly why `dist export` excludes
|
|||||||
- **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body
|
- **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body
|
||||||
alone would otherwise be misled - a changed decision, a dropped criterion, a
|
alone would otherwise be misled - a changed decision, a dropped criterion, a
|
||||||
new constraint. A comment carries the changelog line for that rewrite, and
|
new constraint. A comment carries the changelog line for that rewrite, and
|
||||||
nothing else that a future session needs in order to act.
|
nothing else that a future session needs in order to act. Closing an issue is
|
||||||
|
always a rewrite - see step 7.
|
||||||
- **An old issue carries only `prio/` and `size/`?** Complete it to all four
|
- **An old issue carries only `prio/` and `size/`?** Complete it to all four
|
||||||
when you touch it, rather than in a sweep. The board reaches the new scheme
|
when you touch it, rather than in a sweep. The board reaches the new scheme
|
||||||
issue by issue, as each is picked up.
|
issue by issue, as each is picked up.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
name: stack-close
|
||||||
|
description: Close out a stack-dev work package after its publish has landed - rewrite the issue body to its final state, check for docs/ staleness, and name which model ran which phase of the session. Use right after a stack-dev session's tools/wikitool publish succeeds, or when resuming a package that was published but never closed.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stack Close
|
||||||
|
|
||||||
|
**Purpose:** Carry out the unchecked closing phase of a stack-development work package, as its
|
||||||
|
own skill rather than a break `stack-dev` has to remember to ask for mid-flow.
|
||||||
|
|
||||||
|
**Trigger:** A `stack-dev` session's `tools/wikitool publish` just succeeded - `stack-dev` ends
|
||||||
|
there and hands off here rather than continuing into this phase in the same breath. Also: `publish`
|
||||||
|
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
||||||
|
closed the work package it belongs to yet; or a package was published in an earlier session and
|
||||||
|
never went through this skill (the gap this split exists to make impossible to skip past
|
||||||
|
silently - see [issue-tracking.md](../issue-tracking.md)'s note that a closed body is the version
|
||||||
|
everyone reads afterwards and nobody revisits).
|
||||||
|
|
||||||
|
**This directory is dev-only.** Same boundary as `stack-dev`
|
||||||
|
([its own note](../stack-dev/SKILL.md) has the full reasoning) - `dist export` prunes
|
||||||
|
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
||||||
|
|
||||||
|
## Why this is a separate skill, not `stack-dev`'s step 6
|
||||||
|
|
||||||
|
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
||||||
|
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
||||||
|
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
||||||
|
state (see [claude-code-model-selection.md](../../claude-code-model-selection.md)). Asking the
|
||||||
|
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
||||||
|
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
||||||
|
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
||||||
|
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: 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).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Offer the model switch back up, once, and keep working either way.**
|
||||||
|
|
||||||
|
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
|
||||||
|
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
|
||||||
|
|
||||||
|
**Never block on the answer.** The change is already published; a session that stops here
|
||||||
|
leaves exactly the state this skill exists to prevent.
|
||||||
|
|
||||||
|
2. **Rewrite the issue body to its final state, then close.** The test is what a reader who
|
||||||
|
opens the closed issue tomorrow would conclude:
|
||||||
|
|
||||||
|
- every acceptance criterion ticked, or struck with the reason it was dropped
|
||||||
|
- proposals that were decided read as decided; a "to decide" section has become the decision
|
||||||
|
and its reasoning
|
||||||
|
- nothing left in the present tense about a defect that no longer exists
|
||||||
|
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
||||||
|
|
||||||
|
Then one short comment naming what changed against the previous state, and nothing else -
|
||||||
|
[issue-tracking.md](../issue-tracking.md) steps 2-3 and 7 have the full shape; this is that
|
||||||
|
procedure, run at the point this skill exists to guarantee it actually gets run.
|
||||||
|
|
||||||
|
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||||
|
complete to whoever writes it and leaves a body still phrased as open work. Nothing mechanical
|
||||||
|
catches it, which is why this is a step - and now a whole skill - rather than a habit. #44 and
|
||||||
|
#45 both closed exactly this way on the old, single-skill shape, the second an hour after the
|
||||||
|
rule was first written down.
|
||||||
|
|
||||||
|
3. **Check whether a `docs/` page or new human doc went stale.** A `docs/` page carries no
|
||||||
|
normative sentence, so nothing verifies it by construction (AGENTS.md § File naming) - the
|
||||||
|
same is true of `README.md`/`INSTALL.md`/`DEVELOPMENT.md` prose and a new instruction's own
|
||||||
|
wording, which `instructions verify` checks structurally but never for what it claims. If the
|
||||||
|
change this package shipped moved the reasoning one of these pages documents, update it now;
|
||||||
|
if none did, say so rather than leaving the question unasked.
|
||||||
|
|
||||||
|
4. **Name which model ran which phase - not only this one.** This is the handover in full, not
|
||||||
|
a note about the tail alone: state the model for the design/version-part/boundary-judgment
|
||||||
|
phase (`stack-dev` step 3), for the mechanical middle (code, tests, the version bump), and for
|
||||||
|
this closing phase - all three, even when they are all the same model. A handover that only
|
||||||
|
flags a cheap-model *closing* phase stays silent exactly when the earlier, equally unchecked
|
||||||
|
design phase also ran cheap and nobody offered the switch back then either; naming all three
|
||||||
|
every time is what keeps that omission from being the quiet default.
|
||||||
|
|
||||||
|
## Decision points
|
||||||
|
|
||||||
|
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
||||||
|
actually finished and its last publish has landed - not after every individual publish. A
|
||||||
|
package still open across sessions keeps its body current per
|
||||||
|
[issue-tracking.md](../issue-tracking.md) step 2 in the meantime; that is maintenance, not
|
||||||
|
closing.
|
||||||
|
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
||||||
|
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
||||||
|
it "correctly." The handover in step 4 names the earlier phases from the historical record
|
||||||
|
(the issue's comments, `CHANGES.md`) rather than from memory.
|
||||||
|
- **Nothing to close - the session's own exploration, no publish happened?** This skill does not
|
||||||
|
apply; there is no package to rewrite a body for.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Follows a `stack-dev` session's publish. Not for wiki content work - use
|
||||||
|
`wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/`wiki-status` for that, whose own closing
|
||||||
|
conventions (`kb/log.md`, page provenance) are unrelated to this tracker-body procedure.
|
||||||
@@ -43,18 +43,60 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
engineering, memory and deploy-time learning; consult before a design decision in those
|
engineering, memory and deploy-time learning; consult before a design decision in those
|
||||||
areas.
|
areas.
|
||||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
||||||
package, labelled `area/`, `kind/`, `prio/` and `size/`, with the body kept as the current
|
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||||
truth rather than as a first post. There is no `TODO.md`. Read it before filing something
|
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||||
for later, before editing an issue, or before deciding what to pick up next.
|
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||||
|
to its final state before closing. Both halves bind; the second is what
|
||||||
|
[`stack-close`](../stack-close/SKILL.md) carries out once this skill's own work is published -
|
||||||
|
see step 5 below. Read it before filing something for later, before editing or closing an
|
||||||
|
issue, or before deciding what to pick up next.
|
||||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
||||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||||
establish itself. Read it before adding or changing a test.
|
establish itself. Read it before adding or changing a test.
|
||||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
||||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||||
put in front of the user before a breaking bump. Read it before step 3.
|
put in front of the user before a breaking bump. Read it before step 4.
|
||||||
|
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||||
|
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||||
|
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||||
|
before any fix that would touch `kb/` content.
|
||||||
More instructions are added here incrementally as stack-development needs come up - this
|
More instructions are added here incrementally as stack-development needs come up - this
|
||||||
list grows without needing this skill file to change shape.
|
list grows without needing this skill file to change shape.
|
||||||
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
3. **Settle the design before building - and break there for the model switch.** These are two
|
||||||
|
different kinds of work, and the split is not stylistic: design, the version part and any
|
||||||
|
boundary judgment have **no** mechanical guard, while the code and tests that follow are mostly
|
||||||
|
covered - `pytest`, `docs verify`, `instructions verify` and CI catch a mistake **in what they
|
||||||
|
cover**.
|
||||||
|
|
||||||
|
So when the design is settled - the issue body says what will be built, the open questions are
|
||||||
|
answered - stop and say so, in one sentence that names what the mechanical stretch does **not**
|
||||||
|
cover:
|
||||||
|
|
||||||
|
> Der Plan steht, ab hier ist die Arbeit größtenteils mechanisch und durch Tests/CI abgedeckt -
|
||||||
|
> mit Ausnahme der Changelog-Prosa (Schritt 4), einer berührten `docs/`-Seite, neuer
|
||||||
|
> Menschendoku oder des Prosa-Anteils einer Instruction. Wenn du auf Opus bist, ist jetzt der
|
||||||
|
> Moment für `/model sonnet` bei Effort `high`.
|
||||||
|
|
||||||
|
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
||||||
|
setting an agent applies. Offer it once and keep working either way; a session that argues
|
||||||
|
about its own model has already cost more than the difference. If the design turns out not to
|
||||||
|
be settled after all - a boundary crossing surfaces, an assumption breaks - that is a reason to
|
||||||
|
offer the switch back up, not to decide it alone.
|
||||||
|
|
||||||
|
**"Covered by tests" means covered by the tests that exist, not by the tests that should
|
||||||
|
exist.** Whether the right test was written is itself a judgment call with no mechanical
|
||||||
|
guard: two data-destroying bugs in `upstream merge` (Gitea #30) shipped past a green
|
||||||
|
`pytest`/`docs verify`/`instructions verify`/CI because no test exercised the case, not
|
||||||
|
because a weaker model wrote worse code for the case that *was* tested. This is not a third
|
||||||
|
break - it is a caveat on this one: the middle phase stays the cheaper phase to run on, but its
|
||||||
|
test suite is only as complete as the judgment that wrote it, and that judgment is unchecked
|
||||||
|
the same way the design phase is.
|
||||||
|
|
||||||
|
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||||
|
than one file or a contract. Full table and reasoning:
|
||||||
|
[claude-code-model-selection.md](../../claude-code-model-selection.md).
|
||||||
|
|
||||||
|
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||||
version and a changelog entry:
|
version and a changelog entry:
|
||||||
|
|
||||||
@@ -91,12 +133,23 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
||||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||||
|
|
||||||
4. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
5. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
||||||
verify`, and the relevant `pytest` run in `tools/` - the same checks any stack change must
|
and the relevant `pytest` run in `tools/` - the same checks any stack change must pass, run
|
||||||
pass, run explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a
|
explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a full
|
||||||
full `setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves
|
`setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves `VERSION`
|
||||||
`VERSION` additionally triggers a tagged release. **CI does the tagging** - a session never
|
additionally triggers a tagged release. **CI does the tagging** - a session never creates a
|
||||||
creates a tag, which is what keeps AGENTS.md invariant 5 intact.
|
tag, which is what keeps AGENTS.md invariant 5 intact.
|
||||||
|
|
||||||
|
Publish with `tools/wikitool publish`. When the changeset touches `tools/`, `types/`,
|
||||||
|
`instructions/`, `AGENTS.md` or a `<stage>/CONTRACT.md`, `publish` itself prints a one-line
|
||||||
|
reminder that the phase past this point is not covered by any of the checks above - that line
|
||||||
|
is the cue that this skill's own job just ended.
|
||||||
|
|
||||||
|
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
||||||
|
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
||||||
|
in [`stack-close`](../stack-close/SKILL.md), not in a further step of this one. Invoke it now;
|
||||||
|
do not fold its work into this session under this skill's rules, and do not treat "the change
|
||||||
|
is published" as this work package being done.
|
||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
|
|
||||||
@@ -108,10 +161,14 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||||
[version-parts.md](../version-parts.md) step 4 has the full shape.
|
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
||||||
|
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||||
|
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||||
|
part was chosen correctly.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
||||||
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
||||||
a fresh clone of this repo (`instructions/bootstrap.md`).
|
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
||||||
|
its publish has landed - that is [`stack-close`](../stack-close/SKILL.md).
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewri
|
|||||||
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
||||||
boundary between tests, not for one inside a test.
|
boundary between tests, not for one inside a test.
|
||||||
|
|
||||||
|
## Which tree a test writes into
|
||||||
|
|
||||||
|
The environment is one half of the isolation; `config.ROOT` is the other. With `CHEMENU_ROOT`
|
||||||
|
cleared, `ROOT` falls back to the checkout pytest is running from - deliberately, because most
|
||||||
|
tests want the shipped `types/`. It also means that any code path resolving a file through
|
||||||
|
`config.ROOT` or `config.KB_DIR` reaches **the real repository**, no matter which tree the
|
||||||
|
fixture built.
|
||||||
|
|
||||||
|
Both corpus fixtures therefore repoint it: `raw_dir` and `kb_dir` each set
|
||||||
|
`config.ROOT` to their `tmp_path` and re-declare the shipped `types/` through
|
||||||
|
`use_shipped_type_specs()`. `config`'s module `__getattr__` resolves the derived paths on
|
||||||
|
access, so repointing `ROOT` carries `KB_DIR`, `RAW_DIR` and the rest with it. A new fixture
|
||||||
|
that builds a tree does the same thing - that is the rule here, not a per-test judgment.
|
||||||
|
|
||||||
|
`kb_dir` did not, until Gitea #44. Two things came of that. A test calling
|
||||||
|
`kb_state.write_kb_state()` overwrote the real `.wikitool-kb.json`, which `git status` made
|
||||||
|
visible within the minute. Quieter and worse: `lint`'s collection lookup resolved a page
|
||||||
|
against `config.KB_DIR`, so every fixture page read back as "no collection" and the
|
||||||
|
`unauthorised_labels` check skipped every edge in silence - the finding had no working test at
|
||||||
|
all, and its green run read like an assurance.
|
||||||
|
|
||||||
|
Two guards came out of it, both in `conftest.py`:
|
||||||
|
|
||||||
|
| Guard | Default | Cost |
|
||||||
|
|---|---|---|
|
||||||
|
| `repository_tree_guard` (session) | on | two `git status --porcelain` calls per run |
|
||||||
|
| `per_test_tree_guard` | off, `CHEMENU_TREE_GUARD=each` turns it on | one `git status` per test |
|
||||||
|
|
||||||
|
The session guard compares the working tree before against after and fails the run if anything
|
||||||
|
moved, so it says nothing about uncommitted work a developer already had. It cannot name the
|
||||||
|
test that did it; `CHEMENU_TREE_GUARD=each` can, and is the way to bisect once it fires. Where
|
||||||
|
git is unavailable or the checkout is not a repository, both are silently inert.
|
||||||
|
|
||||||
|
Neither guard sees the second, quieter half: a check that silently *does nothing* under test
|
||||||
|
writes no file. That one is only caught by a test that asserts the finding actually fires -
|
||||||
|
which is why `test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb`
|
||||||
|
lints a tree `ROOT` deliberately points away from.
|
||||||
|
|
||||||
|
**A function that takes a directory resolves against that directory.** `run_lint(kb_dir)`
|
||||||
|
reading `config.KB_DIR` for one of its own lookups was the defect behind the quiet half, and
|
||||||
|
no fixture can fix that shape from the outside.
|
||||||
|
|
||||||
## When to run
|
## When to run
|
||||||
|
|
||||||
Whenever you add or change a test under `tools/chemenu/tests/`.
|
Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||||
@@ -80,7 +122,12 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
|||||||
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
||||||
is the exact hole this whole file is about, reopened.
|
is the exact hole this whole file is about, reopened.
|
||||||
|
|
||||||
5. **Verify against an empty machine before publishing**, not only in your own shell:
|
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
||||||
|
`use_shipped_type_specs(monkeypatch)`, as `raw_dir` and `kb_dir` do - see
|
||||||
|
[Which tree a test writes into](#which-tree-a-test-writes-into). A fixture that returns a
|
||||||
|
path without repointing hands the code under test the real repository.
|
||||||
|
|
||||||
|
6. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
|
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
|
||||||
@@ -92,7 +139,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
|||||||
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
|
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
|
||||||
variable belongs in step 4's list.
|
variable belongs in step 4's list.
|
||||||
|
|
||||||
6. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
7. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||||
lines were uncovered:
|
lines were uncovered:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Two questions decide a version bump, and they are **not the same question**:
|
|||||||
|
|
||||||
1. **Is the new version a drop-in replacement for the old one?** This is what the version
|
1. **Is the new version a drop-in replacement for the old one?** This is what the version
|
||||||
number itself says. Compatibility is read off the **leftmost non-zero component** - on this
|
number itself says. Compatibility is read off the **leftmost non-zero component** - on this
|
||||||
stack (`2.x`) that is MAJOR, on a `0.x` stack it is MINOR. A bump that changes it is called
|
stack (`4.x`) that is MAJOR, on a `0.x` stack it is MINOR. A bump that changes it is called
|
||||||
*boundary-crossing* below, because that is the term `version bump` and `docs verify` use in
|
*boundary-crossing* below, because that is the term `version bump` and `docs verify` use in
|
||||||
their own messages.
|
their own messages.
|
||||||
2. **Must existing content be migrated?** This is a *consequence* a boundary crossing may or
|
2. **Must existing content be migrated?** This is a *consequence* a boundary crossing may or
|
||||||
@@ -20,6 +20,38 @@ Two questions decide a version bump, and they are **not the same question**:
|
|||||||
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
||||||
already (see the case study at the end), which is why this file exists.
|
already (see the case study at the end), which is why this file exists.
|
||||||
|
|
||||||
|
## The candidate model
|
||||||
|
|
||||||
|
Between two releases the stack carries **one running candidate**, not a fresh version per
|
||||||
|
`bump`. Before that, every bump minted a number *and* a release: CI's version gate requires
|
||||||
|
`VERSION` to move on every stack-touching push, and `release.yml` fires on every `VERSION`
|
||||||
|
move, so releases were being cut at commit granularity. 2026-09-03 produced four of them in
|
||||||
|
six hours (`4.3.0` through `4.3.3`) for one arc of work - all four real, none of them a
|
||||||
|
meaningful unit to anyone downstream. A candidate closes that gap without touching the gate:
|
||||||
|
`VERSION` still moves on every bump, it just escalates the *same* number instead of handing out
|
||||||
|
a new one, and only `version release` turns it into something the release workflow acts on.
|
||||||
|
|
||||||
|
- **State lives in `VERSION` itself**, as an optional `-beta.N` suffix (`4.4.0-beta.3`). No
|
||||||
|
second state file: the last release is read back out of `CHANGES.md` (the newest entry with no
|
||||||
|
suffix), and the escalation stage is the difference between the candidate's base and that
|
||||||
|
release - derived, not stored.
|
||||||
|
- **`--major`/`--minor`/`--patch` is max-wins escalation**, not a step you can undo. A `--patch`
|
||||||
|
bump on a candidate already at MINOR only advances its bump count (`N`); nothing ever steps a
|
||||||
|
candidate back down. Declaring the part is still your judgment call, made the same way the
|
||||||
|
steps below describe - `escalate()` only ever raises it further.
|
||||||
|
- **A candidate is never released.** Pre-release is a dev-checkout state; `release.yml` only acts
|
||||||
|
on a suffix-free `VERSION`, so a distributed instance never sees a `-beta.` version at all, and
|
||||||
|
its parser never has to know the suffix exists.
|
||||||
|
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
|
||||||
|
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
|
||||||
|
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
|
||||||
|
heading, date and the bumps list all move, but the entry's own prose (written below the
|
||||||
|
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
||||||
|
release, whatever a candidate's history of bumps looked like.
|
||||||
|
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
|
||||||
|
strips the suffix and turns the entry into a real, closed release - see its own row in
|
||||||
|
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
|
||||||
|
|
||||||
## When to run
|
## When to run
|
||||||
|
|
||||||
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
||||||
@@ -63,9 +95,11 @@ the three-line test below is usually enough.
|
|||||||
| Fix, no interface change | `--patch` |
|
| Fix, no interface change | `--patch` |
|
||||||
| New capability, drop-in in both directions | `--minor` |
|
| New capability, drop-in in both directions | `--minor` |
|
||||||
|
|
||||||
4. **Stop and talk to the user before a boundary-crossing bump.** It is expensive in a way the
|
4. **Stop and talk to the user before the bump that first escalates a candidate past the
|
||||||
other two parts are not: every existing instance pays for it, once, by hand. Put in front of
|
boundary.** It is expensive in a way the other two parts are not: every existing instance pays
|
||||||
them, in this order:
|
for it, once, by hand. That escalation happens exactly once per candidate - a later bump that
|
||||||
|
keeps the candidate at the same stage (another `--major` on one already there, say) does not
|
||||||
|
re-cross anything and needs no second conversation. Put in front of the user, in this order:
|
||||||
|
|
||||||
- **What breaks**, concretely - which file, which name, which call site.
|
- **What breaks**, concretely - which file, which name, which call site.
|
||||||
- **What each existing instance must do**, as the steps they would actually run.
|
- **What each existing instance must do**, as the steps they would actually run.
|
||||||
@@ -81,8 +115,9 @@ the three-line test below is usually enough.
|
|||||||
|
|
||||||
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
|
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
|
||||||
|
|
||||||
5. **Record the break in the bump itself.** A boundary-crossing bump requires
|
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
|
||||||
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
|
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
||||||
|
entry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool version bump --major \
|
tools/wikitool version bump --major \
|
||||||
@@ -91,22 +126,33 @@ the three-line test below is usually enough.
|
|||||||
--no-migration "<why no page has to change>" # only if that is true
|
--no-migration "<why no page has to change>" # only if that is true
|
||||||
```
|
```
|
||||||
|
|
||||||
`--breaking` is refused on a bump that crosses nothing, and required on one that does;
|
The line, once written, stays in the entry across every later bump of the same candidate -
|
||||||
`docs verify` checks the newest boundary-crossing entry still carries the line. Write it for
|
a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat
|
||||||
the operator of an instance that has not read this repository: what stops working, and what
|
it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required
|
||||||
they do about it.
|
on the one that does. `docs verify` checks the newest boundary-crossing entry still carries
|
||||||
|
the line. Write it for the operator of an instance that has not read this repository: what
|
||||||
|
stops working, and what they do about it.
|
||||||
|
|
||||||
6. **Then answer the migration question separately.** Boundary-crossing and
|
6. **Then answer the migration question separately.** Boundary-crossing and
|
||||||
content-migrating are independent:
|
content-migrating are independent:
|
||||||
|
|
||||||
- Content must change → write the migration document under `instructions/migrations/` per
|
- Content must change → write the migration document under `instructions/migrations/` per
|
||||||
[migrate-corpus.md](../migrate-corpus.md). `bump` finds it by its `migrates_to:` field.
|
[migrate-corpus.md](../migrate-corpus.md). The escalation bump finds it by the document's
|
||||||
|
`migrates_to:` field, matched against the candidate's **base** - a document targets the
|
||||||
|
release the candidate will become, never a `-beta.N` form of it.
|
||||||
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
||||||
|
|
||||||
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
||||||
migrate, with nothing telling it how, is a dead end.
|
migrate, with nothing telling it how, is a dead end. Like `--breaking`, both persist across
|
||||||
|
later bumps of the same candidate without being repeated.
|
||||||
|
|
||||||
7. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
|
||||||
|
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
||||||
|
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
||||||
|
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
||||||
|
way; without one, the heading simply keeps whichever bump last set it.
|
||||||
|
|
||||||
|
8. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
||||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
cannot reconstruct from the diff, and it is what the next session in this position will read
|
||||||
instead of guessing.
|
instead of guessing.
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ The setup this gate exists for - a private instance that takes stack updates fro
|
|||||||
upstream - is [private-instance.md](private-instance.md). Step 4 there arms it, deliberately
|
upstream - is [private-instance.md](private-instance.md). Step 4 there arms it, deliberately
|
||||||
*before* the first `publish`: added afterwards it leaves open exactly the window it closes.
|
*before* the first `publish`: added afterwards it leaves open exactly the window it closes.
|
||||||
|
|
||||||
|
### Mass-Update Gate blind spot: `upstream merge`
|
||||||
|
|
||||||
|
`upstream merge` (a private instance taking a stack update - see
|
||||||
|
[private-instance.md](private-instance.md)) can update or delete dozens of stack-owned paths in
|
||||||
|
one commit, and the Mass-Update Gate does not see any of it. The gate counts *working-tree*
|
||||||
|
changes before `publish` stages them; by the time `upstream merge` commits, the change is
|
||||||
|
already history, and the commit it made is not what a later `publish` would be staging - that
|
||||||
|
publish sees only whatever this session adds on top. A merge touching 200 files therefore goes
|
||||||
|
out ungated the moment it is pushed.
|
||||||
|
|
||||||
|
This is not a hole to patch by making `upstream merge` route through the gate: the gate's
|
||||||
|
question ("is this too much to publish?") does not apply to a change that only ever touches
|
||||||
|
stack-owned paths that are, by definition, not this instance's own content. The check that
|
||||||
|
actually matters here is `upstream merge`'s own postcheck - it re-verifies the merge commit
|
||||||
|
against `upstream verify`'s logic immediately after committing, and exits 1 with the offending
|
||||||
|
paths if anything landed outside a stack-owned one. **The merge commit is deliberately left in
|
||||||
|
place** rather than reverted: it exists, a human has to look at it, and a command that quietly
|
||||||
|
repaired its own mistake would hide the one event worth seeing. That postcheck is the safeguard
|
||||||
|
for this command, not the Mass-Update Gate.
|
||||||
|
|
||||||
## Iteration Budget Gate and loop-breaker
|
## Iteration Budget Gate and loop-breaker
|
||||||
|
|
||||||
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
|
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ stack's hardcoded behaviour until the conventions file existed.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `language:` | `de` |
|
| `language:` | `de` |
|
||||||
| `sections:` | `Beziehungen` / `Siehe auch` / `Fußnoten` |
|
| `sections:` | `Beziehungen` / `Siehe auch` / `Fußnoten` |
|
||||||
| Naming | Human-readable titles with spaces; singular for entities; `adr-NNN-` for decisions; `X vs Y` for comparisons |
|
| Naming | Human-readable titles with spaces; singular for entities; a decision named like any other concept, no `adr-NNN-` prefix; `X vs Y` for comparisons |
|
||||||
| Tone | Wikipedia register, with a German buzzword and filler list |
|
| Tone | Wikipedia register, with a German buzzword and filler list |
|
||||||
| Relationship labels | `hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` · `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` |
|
| Relationship labels | `hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` · `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` |
|
||||||
| Confidence rubric | 0.5 base, +0.2 per supporting source (max +0.6), recency and source-quality bonuses; hedge with "möglicherweise"/"kann" below 0.6, "unsicher"/"unbestätigt" below 0.4 |
|
| Confidence rubric | 0.5 base, +0.2 per supporting source (max +0.6), recency and source-quality bonuses; hedge with "möglicherweise"/"kann" below 0.6, "unsicher"/"unbestätigt" below 0.4 |
|
||||||
|
|||||||
@@ -40,9 +40,28 @@ edge merely to mirror the first one.** The inbound view is rendered from the gra
|
|||||||
`index rebuild` and `search`, so a reader landing on the target sees what points at it whether
|
`index rebuild` and `search`, so a reader landing on the target sees what points at it whether
|
||||||
or not anyone wrote a second edge.
|
or not anyone wrote a second edge.
|
||||||
|
|
||||||
That is why most labels below have no inverse. Only two pairs do, because in each the reverse
|
That is why most labels below have no inverse. Only three pairs do, because in each the reverse
|
||||||
direction is a genuine primary statement someone would write on its own: `depends-on` /
|
direction is a genuine primary statement someone would write on its own: `depends-on` /
|
||||||
`required-by` and `runs-on` / `hosts`.
|
`required-by`, `runs-on` / `hosts`, and `composition` / `part-of`.
|
||||||
|
|
||||||
|
**A self-dual label is still written once.** `alternative-to` is its own inverse - the sentence
|
||||||
|
reads identically from either end - and that makes it the easiest label in the catalogue to
|
||||||
|
write twice by reflex. Symmetry means the relation holds in both directions, not that both pages
|
||||||
|
must declare it: one edge per pair, and the other page's inbound view carries it. The difference
|
||||||
|
is not cosmetic at scale. Seven mutually substitutable tools are 21 pairs; declared once each
|
||||||
|
that is 21 edges, declared from both ends it is 42, and the second 21 say nothing the first did
|
||||||
|
not. This is the shape a `see-also` clique already had in this corpus before the labels existed,
|
||||||
|
and relabelling such a clique without dropping to one edge per pair moves the problem rather
|
||||||
|
than fixing it.
|
||||||
|
|
||||||
|
The third was added after the 4.0.0 migration, from measurement rather than from the desk. A
|
||||||
|
parent-child structure - a tier list and its tiers, a spectrum and its levels - produces the
|
||||||
|
question on nearly every page: the parent writes `composition`, and the child then reaches for
|
||||||
|
either `part-of` or `see-also`. The migration run answered `see-also`, on the reading that
|
||||||
|
`part-of` would be a mirror, and left sixteen edges saying "these two are related" about a
|
||||||
|
relationship the catalogue already had a word for. It is not a mirror: the parent's sentence
|
||||||
|
lists its parts, the child's names the whole it belongs to, and a reader landing on the child
|
||||||
|
needs the second one.
|
||||||
|
|
||||||
## When to run
|
## When to run
|
||||||
|
|
||||||
@@ -91,10 +110,23 @@ entity to entity.
|
|||||||
| `consumes` | — | reads the target as an artifact or data |
|
| `consumes` | — | reads the target as an artifact or data |
|
||||||
| `maintains` | — | carries the upkeep of the target |
|
| `maintains` | — | carries the upkeep of the target |
|
||||||
| `owns` | — | is accountable for the target's existence and decisions |
|
| `owns` | — | is accountable for the target's existence and decisions |
|
||||||
|
| `authored` | — | created the target as a one-time act |
|
||||||
|
| `alternative-to` | itself | serves the same purpose as the target, so a reader choosing between them wants both |
|
||||||
|
|
||||||
`uses` versus `depends-on` is the distinction worth keeping sharp: if removing the target breaks
|
`uses` versus `depends-on` is the distinction worth keeping sharp: if removing the target breaks
|
||||||
this thing, it is `depends-on`. `owns` versus `maintains`: accountability versus labour, and
|
this thing, it is `depends-on`.
|
||||||
they are often different people.
|
|
||||||
|
`authored`, `owns` and `maintains` are three different sentences about the same pair, and often
|
||||||
|
three different people: origination, accountability, labour. `owns` is a *standing* claim - it
|
||||||
|
says someone answers for this thing now - so it reads false about a person who is dead or long
|
||||||
|
gone from the project, however plainly they made it. That is the case `authored` exists for, and
|
||||||
|
picking `owns` for it is not a weaker edge but a wrong one.
|
||||||
|
|
||||||
|
`alternative-to` versus `contrasts` versus `compares-with`: `contrasts` asserts a *difference
|
||||||
|
worth reading both for*, `alternative-to` asserts *substitutability* - two things a reader might
|
||||||
|
pick between for the same job. `compares-with` weighs them on named dimensions, which in this
|
||||||
|
instance is what routes to a `kb/comparisons/` page. Two agent CLIs are `alternative-to`; two
|
||||||
|
opposed design principles are `contrasts`, and swapping the two says something false about both.
|
||||||
|
|
||||||
### Realization
|
### Realization
|
||||||
|
|
||||||
@@ -126,13 +158,28 @@ Inference and comparison between ideas.
|
|||||||
| `contrasts` | differs from the target in a way worth reading both for |
|
| `contrasts` | differs from the target in a way worth reading both for |
|
||||||
| `compares-with` | is weighed against the target on shared dimensions |
|
| `compares-with` | is weighed against the target on shared dimensions |
|
||||||
| `contradicts` | asserts something the target denies |
|
| `contradicts` | asserts something the target denies |
|
||||||
|
| `addresses` | is a response to the problem the target describes |
|
||||||
| `composition` | is composed of the target |
|
| `composition` | is composed of the target |
|
||||||
| `part-of` | is a component of the target |
|
| `part-of` | is a component of the target |
|
||||||
|
|
||||||
|
`composition` / `part-of` is the third **inverse pair**, alongside `depends-on` / `required-by`
|
||||||
|
and `runs-on` / `hosts` in the operational register. Being a pair does not make the second edge
|
||||||
|
obligatory - direction is still authored - it settles *which label* the second edge takes when
|
||||||
|
someone does write it. The child of a `composition` writes `part-of`, not `see-also`: what it
|
||||||
|
is a component of is a primary statement about the child, and `see-also` says strictly less
|
||||||
|
about the same fact.
|
||||||
|
|
||||||
`grounds` / `rests-on` is a genuine pair and both directions are primary statements; they are
|
`grounds` / `rests-on` is a genuine pair and both directions are primary statements; they are
|
||||||
listed separately rather than as inverses because either page may legitimately carry only its
|
listed separately rather than as inverses because either page may legitimately carry only its
|
||||||
own side.
|
own side.
|
||||||
|
|
||||||
|
`addresses` is the edge from a solution to the problem it answers - a decision to the trouble
|
||||||
|
that forced it, a mechanism to the failure it prevents. Keep it apart from `rests-on`, which
|
||||||
|
takes the target as a *premise* the source argues from: a decision usually does both, and the
|
||||||
|
one worth writing is the one a reader here would follow. `addresses` has no inverse. The problem
|
||||||
|
page's inbound view already answers "what did anyone do about this?", which is the only reason
|
||||||
|
someone would want the reverse.
|
||||||
|
|
||||||
### Lineage
|
### Lineage
|
||||||
|
|
||||||
Where something came from, and what replaced it.
|
Where something came from, and what replaced it.
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ you know the run is finished.
|
|||||||
**Nothing breaks while it is outstanding.** Unlabelled edges and undelimited regions are read,
|
**Nothing breaks while it is outstanding.** Unlabelled edges and undelimited regions are read,
|
||||||
not rejected: `links.py` treats a bare title as an edge whose label is not declared yet, and
|
not rejected: `links.py` treats a bare title as an edge whose label is not declared yet, and
|
||||||
`provenance.split_cite_block` falls back to the pre-marker layout. That is deliberate - a corpus
|
`provenance.split_cite_block` falls back to the pre-marker layout. That is deliberate - a corpus
|
||||||
has to stay readable while it is being converted - and it is why the two lint findings are
|
has to stay readable while it is being converted - and it is why the two lint findings stay
|
||||||
advisory until step 6 promotes them.
|
advisory for as long as `kb_version` is below 4.0.0, which is exactly as long as this document
|
||||||
|
is outstanding.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ advisory until step 6 promotes them.
|
|||||||
is otherwise silent: the region becomes ordinary prose and the next write appends a second
|
is otherwise silent: the region becomes ordinary prose and the next write appends a second
|
||||||
one beside it.
|
one beside it.
|
||||||
|
|
||||||
6. **Record it, then tighten the checks:**
|
6. **Record it. The checks tighten themselves:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool lint # unlabelled_edges and unauthorised_labels must be 0
|
tools/wikitool lint # unlabelled_edges and unauthorised_labels must be 0
|
||||||
@@ -110,8 +111,14 @@ advisory until step 6 promotes them.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Only once `lint` reports zero of both is the run finished. The two findings are advisory
|
Only once `lint` reports zero of both is the run finished. The two findings are advisory
|
||||||
during the window and become hard errors afterwards - the same path
|
while `kb_version` is below 4.0.0 and hard from the moment `migrate done` records it -
|
||||||
`legacy_citation_markers` took after the citation migration.
|
nothing to flip by hand, and no window in which a half-converted corpus is refused by the
|
||||||
|
check that is measuring its progress.
|
||||||
|
|
||||||
|
Do not record the migration to silence the findings. The promotion is what makes the run
|
||||||
|
stick: after it, a bare title in `related:` is a hard error rather than a page still
|
||||||
|
waiting, so a corpus recorded early fails its next lint instead of quietly keeping the old
|
||||||
|
shape.
|
||||||
|
|
||||||
## How to tell a migrated page from an unmigrated one
|
## How to tell a migrated page from an unmigrated one
|
||||||
|
|
||||||
|
|||||||
@@ -108,69 +108,53 @@ So the merge has to be scoped. That is the procedure below, and it is not option
|
|||||||
|
|
||||||
## Taking a stack update
|
## Taking a stack update
|
||||||
|
|
||||||
Take the machinery, never the content. The merge is held open, the content stages are forced
|
```bash
|
||||||
back to your own state, and only then does it close.
|
tools/wikitool upstream merge --remote upstream --branch main
|
||||||
|
```
|
||||||
|
|
||||||
**Three files under those stages are machinery, not content**, and forcing them back is how an
|
Take the machinery, never the content. This is the command form of the same idea a hand-rolled
|
||||||
upstream contract change gets silently discarded:
|
merge would need: hold the merge open, force the content stages back to your own state, restore
|
||||||
|
only the paths that are machinery, and only then let it close. Which paths those are is not a
|
||||||
|
short literal list any more (see below) - it is `chemenu.ownership.is_stack_owned`, the same
|
||||||
|
predicate `dist_cmd.py`'s export reads, so a stack change that adds a new machinery path under a
|
||||||
|
content stage is recognised automatically rather than needing this document edited first.
|
||||||
|
|
||||||
| Path | Why it must take the upstream side |
|
**What counts as machinery under a content stage**, for readers who want the shape rather than
|
||||||
|
the code:
|
||||||
|
|
||||||
|
| Path | Why it takes the upstream side |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `kb/CONTRACT.md` | The stack's own knowledge-layer contract. Every rule in it is enforced by `wikitool`; an instance never edits it |
|
| `<stage>/CONTRACT.md` (`kb/CONTRACT.md`, `raw/CONTRACT.md`, `work/CONTRACT.md`, `reports/CONTRACT.md`) | The stack's own stage contract. Every rule in it is enforced by `wikitool`; an instance never edits it |
|
||||||
| `kb/CONVENTIONS.md.template` | The template your `kb/CONVENTIONS.md` was filled from. The filled file is yours; the template is the stack's |
|
| any `*.template` under a content stage (`kb/CONVENTIONS.md.template`, each `kb/<name>/COLLECTION.md.template`, and any later one) | The template your filled file was adopted from. The filled file is yours; the template is the stack's |
|
||||||
| `raw/CONTRACT.md` | The raw stage's contract, for the same reason as the first row |
|
|
||||||
|
|
||||||
Everything else under `kb/` and `raw/` is yours, `kb/CONVENTIONS.md` and each
|
Everything else under `kb/`, `raw/`, `work/` and `reports/` is yours, `kb/CONVENTIONS.md` and
|
||||||
`kb/<name>/COLLECTION.md` included - they bind your corpus, and they are exactly what the
|
each `kb/<name>/COLLECTION.md` included - they bind your corpus, and they are exactly what
|
||||||
restore below is protecting.
|
`upstream merge` protects.
|
||||||
|
|
||||||
```bash
|
**Your local, uncommitted-by-design files under those stages survive.** Forcing a content stage
|
||||||
BEFORE=$(git rev-parse HEAD)
|
back to your own state removes only what git tracks, never the directory wholesale - which
|
||||||
git fetch upstream
|
matters because `reports/` is gitignored apart from its contract, so it holds data that is in no
|
||||||
|
commit and cannot be recomputed: the telemetry traces `eval score` reads, saved eval reports,
|
||||||
|
past lint reports. A merge has no business touching any of it, and does not.
|
||||||
|
|
||||||
# --no-commit holds the merge open; it may report conflicts under kb/ or raw/,
|
The command itself checks its own result the same way `upstream verify` would, immediately
|
||||||
# which the next four lines are about to make irrelevant.
|
after committing, and refuses loudly - without rolling the commit back - if anything landed
|
||||||
git merge --no-commit --no-ff upstream/main || true
|
outside a stack-owned path. A refusal there is a bug report, not something to work around by
|
||||||
|
hand; see [tools/CONTRACT.md](../tools/CONTRACT.md) for the full error contract, including what
|
||||||
# Whatever the merge did to the content stages, undo it. HEAD is still your
|
a real conflict in `tools/`/`types/`/`instructions/` leaves behind.
|
||||||
# pre-merge commit while the merge is open, so this restores exactly your side.
|
|
||||||
git rm -rq --cached --ignore-unmatch kb raw
|
|
||||||
rm -rf kb raw
|
|
||||||
git checkout HEAD -- kb raw
|
|
||||||
|
|
||||||
# ...then take the upstream side back for the machinery that lives among it.
|
|
||||||
# MERGE_HEAD is still resolvable while the merge is open.
|
|
||||||
git checkout MERGE_HEAD -- kb/CONTRACT.md kb/CONVENTIONS.md.template raw/CONTRACT.md
|
|
||||||
|
|
||||||
git commit --no-edit
|
|
||||||
```
|
|
||||||
|
|
||||||
Then **check that it worked**, rather than trusting that it did. The same three paths are
|
|
||||||
excluded here, spelled out rather than held in a variable so that the check can be read on its
|
|
||||||
own and copied on its own:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --name-only "$BEFORE" HEAD -- kb raw \
|
|
||||||
| grep -vE '^(kb/CONTRACT\.md|kb/CONVENTIONS\.md\.template|raw/CONTRACT\.md)$'
|
|
||||||
```
|
|
||||||
|
|
||||||
Must print nothing.
|
|
||||||
|
|
||||||
An empty result is the proof that the update touched machinery only. A non-empty one means a
|
|
||||||
path slipped through - inspect it before going further.
|
|
||||||
|
|
||||||
**The exclusion is not cosmetic.** Without it the check reports *empty* for an update that just
|
|
||||||
ate a `kb/CONTRACT.md` change - it would be confirming the failure it exists to catch. If one of
|
|
||||||
the three paths does not appear in the diff at all, that is fine: it means upstream did not
|
|
||||||
touch it.
|
|
||||||
|
|
||||||
Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`,
|
Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`,
|
||||||
`lint`. A `migrate status` with outstanding links means the update crossed a compatibility
|
`lint`. A `migrate status` with outstanding links means the update crossed a compatibility
|
||||||
boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else.
|
boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else.
|
||||||
|
|
||||||
**Why not just `git merge upstream/main`?** Because of the table above: a page the upstream
|
**Why not just `git merge upstream/main`?** A page the upstream *adds* arrives with no conflict
|
||||||
*adds* arrives with no conflict and no message. You would find out when `lint` starts reporting
|
and no message under a plain merge - measured in the table further up this document. You would
|
||||||
pages you never wrote - if you noticed at all.
|
find out when `lint` starts reporting pages you never wrote, if you noticed at all. `upstream
|
||||||
|
merge` closes exactly that gap: the content stages never see the upstream's version at all.
|
||||||
|
|
||||||
|
**Checking a merge you resolved by hand instead** (or auditing a past one): `tools/wikitool
|
||||||
|
upstream verify --since <rev-before> --until <rev-after>` runs the same check `upstream merge`
|
||||||
|
runs on itself, without doing the merge.
|
||||||
|
|
||||||
## Where stack development happens
|
## Where stack development happens
|
||||||
|
|
||||||
@@ -186,17 +170,21 @@ merge above. Nothing is lost by the detour: the fix has to pass that CI either w
|
|||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
|
|
||||||
- **Merge conflict in `kb/` or `raw/`?** Expected, and already handled: the update procedure
|
- **Merge conflict in `kb/`, `raw/`, `work/` or `reports/`?** Expected, and already handled:
|
||||||
above overwrites those stages with your own afterwards, so the conflict resolves itself.
|
`upstream merge` overwrites those stages with your own afterwards, so the conflict resolves
|
||||||
Never resolve one by hand with `git add -A` - that is exactly how the upstream version, which
|
itself. Never resolve one by hand with `git add -A` in a merge you are running yourself
|
||||||
git left sitting in your working tree, gets committed into your instance.
|
instead - that is exactly how the upstream version, which git left sitting in your working
|
||||||
- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop - unless it is one
|
tree, gets committed into your instance.
|
||||||
of the three machinery paths the check excludes, which is the update working as intended. For
|
- **`upstream merge` exits 1 after committing?** Read the message: its own postcheck found
|
||||||
anything else the scoping step did not take: do not publish; find out which path came through
|
content outside a stack-owned path in the commit it just made. The commit is **not** rolled
|
||||||
and where from.
|
back - inspect it (`git show`, or `tools/wikitool upstream verify --since <before> --until
|
||||||
|
HEAD`) and decide by hand whether to revert it, fix forward, or report it as a stack bug. This
|
||||||
|
should not happen; if it does, `chemenu.ownership.is_stack_owned` disagreed with itself between
|
||||||
|
the restore and the check, which is exactly what the shared predicate is meant to prevent.
|
||||||
- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which
|
- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which
|
||||||
step "Where stack development happens" says not to do. Take the upstream side and re-file the
|
step "Where stack development happens" says not to do. `upstream merge` leaves the merge open
|
||||||
change as an issue there.
|
rather than guessing - take the upstream side for the named paths and re-file the change as an
|
||||||
|
issue there, or resolve deliberately and finish the commit yourself.
|
||||||
- **...but you changed how *your pages* are written?** That is not a stack change and the rule
|
- **...but you changed how *your pages* are written?** That is not a stack change and the rule
|
||||||
above does not apply to it. Language, section headings, naming forms, tone, relationship
|
above does not apply to it. Language, section headings, naming forms, tone, relationship
|
||||||
labels and the confidence rubric live in `kb/CONVENTIONS.md`, and each collection's authoring
|
labels and the confidence rubric live in `kb/CONVENTIONS.md`, and each collection's authoring
|
||||||
|
|||||||
@@ -26,8 +26,18 @@ never something an agent has to remember.
|
|||||||
unreadable frontmatter, broken wikilinks, dangling frontmatter references, orphan pages,
|
unreadable frontmatter, broken wikilinks, dangling frontmatter references, orphan pages,
|
||||||
catalog drift, missing fields, duplicate titles, filename/title mismatches, broken
|
catalog drift, missing fields, duplicate titles, filename/title mismatches, broken
|
||||||
`raw_files:` references, raw files claimed by more than one source page, invalid type paths,
|
`raw_files:` references, raw files claimed by more than one source page, invalid type paths,
|
||||||
schema failures and citation/frontmatter drift. **Do not re-derive any of it by reading
|
schema failures, citation/frontmatter drift, and edges whose label is missing, not authorised
|
||||||
pages.**
|
by the source collection, or redundant beside a specific label on the reverse direction.
|
||||||
|
**Do not re-derive any of it by reading pages.**
|
||||||
|
|
||||||
|
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
||||||
|
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
||||||
|
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
||||||
|
clears the reference in *both* directions (see [tools/CONTRACT.md](../../tools/CONTRACT.md)),
|
||||||
|
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
||||||
|
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
||||||
|
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
||||||
|
planned corpus sweep with its own run, never a reaction inside a lint.
|
||||||
|
|
||||||
**To see more of the report, read the file - never run `lint` again.** A second run costs a
|
**To see more of the report, read the file - never run `lint` again.** A second run costs a
|
||||||
budget slot and re-measures a corpus that has not changed. The file at step 9 overwrites this
|
budget slot and re-measures a corpus that has not changed. The file at step 9 overwrites this
|
||||||
|
|||||||
+2
-1
@@ -62,7 +62,8 @@ There is no `## Siehe auch` region any more. It was the reciprocal half of a bid
|
|||||||
- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case.
|
- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case.
|
||||||
- Singular for entities: `ha-core.md`, not `ha-cores.md`.
|
- Singular for entities: `ha-core.md`, not `ha-cores.md`.
|
||||||
- Comparison pages read as a comparison: `Go vs Rust.md`.
|
- Comparison pages read as a comparison: `Go vs Rust.md`.
|
||||||
- ADRs are prefixed: `adr-001-use-go-modules.md`.
|
- A decision (`concept_type: decision`) is named like any other concept - no `adr-NNN-` prefix.
|
||||||
|
See [kb/concepts/COLLECTION.md § Decisions](concepts/COLLECTION.md#decisions).
|
||||||
- Prefer readability over convention when the two conflict.
|
- Prefer readability over convention when the two conflict.
|
||||||
|
|
||||||
What to name a thing: projects use their repository or common name; systems a descriptive
|
What to name a thing: projects use their repository or common name; systems a descriptive
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ type: types/comparison.md
|
|||||||
tags: [kernel, power-management, amd, cpu, driver]
|
tags: [kernel, power-management, amd, cpu, driver]
|
||||||
created: 2026-07-31
|
created: 2026-07-31
|
||||||
entities: [amd-pstate, acpi-cpufreq]
|
entities: [amd-pstate, acpi-cpufreq]
|
||||||
summary: "Vergleich zweier AMD-CPU-Power-Management-Treiber: CPPC-basiertes amd-pstate gegen\xFC\
|
summary: "Vergleich zweier AMD-CPU-Power-Management-Treiber: CPPC-basiertes amd-pstate gegen\xFCber ACPI-basiertem acpi-cpufreq."
|
||||||
ber ACPI-basiertem acpi-cpufreq."
|
related:
|
||||||
|
- compares-with: amd-pstate
|
||||||
|
- compares-with: acpi-cpufreq
|
||||||
---
|
---
|
||||||
# Comparison: amd-pstate vs acpi-cpufreq
|
# Comparison: amd-pstate vs acpi-cpufreq
|
||||||
|
|
||||||
@@ -131,8 +133,9 @@ ls /sys/devices/system/cpu/cpu0/cpufreq/cppc_*
|
|||||||
|
|
||||||
**amd-pstate** stellt einen bedeutenden Fortschritt in der CPU-Energieverwaltung für AMD-Prozessoren dar und bietet fein-körnige Steuerung, bessere Effizienz und verbessertes Batterielebensdauer. **acpi-cpufreq** bleibt ein zuverlässiger Fallback und dient weiterhin älterer Hardware. Die Wahl zwischen ihnen hängt hauptsächlich von Hardware-Unterstützung und Kernel-Version ab, wobei amd-pstate die klare Präferenz für moderne AMD-Systeme ist.
|
**amd-pstate** stellt einen bedeutenden Fortschritt in der CPU-Energieverwaltung für AMD-Prozessoren dar und bietet fein-körnige Steuerung, bessere Effizienz und verbessertes Batterielebensdauer. **acpi-cpufreq** bleibt ein zuverlässiger Fallback und dient weiterhin älterer Hardware. Die Wahl zwischen ihnen hängt hauptsächlich von Hardware-Unterstützung und Kernel-Version ab, wobei amd-pstate die klare Präferenz für moderne AMD-Systeme ist.
|
||||||
|
|
||||||
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **compares-with:** [[amd-pstate]]
|
- **compares-with:** [[amd-pstate]]
|
||||||
- **compares-with:** [[acpi-cpufreq]]
|
- **compares-with:** [[acpi-cpufreq]]
|
||||||
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
+25
-12
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
profile: concepts
|
profile: concepts
|
||||||
outbound:
|
outbound:
|
||||||
concepts: [extends, grounds, rests-on, enables, precondition, exemplifies, abstracted-from, contrasts, compares-with, contradicts, composition, part-of, supersedes, derived-from, adapted-from, see-also]
|
concepts: [extends, grounds, rests-on, enables, precondition, exemplifies, abstracted-from, contrasts, compares-with, contradicts, addresses, alternative-to, composition, part-of, supersedes, derived-from, adapted-from, see-also]
|
||||||
entities: [operationalized-from, mechanism, procedure, applies-when, operates-on, invokes, exemplifies, see-also]
|
entities: [operationalized-from, mechanism, procedure, applies-when, operates-on, invokes, exemplifies, alternative-to, see-also]
|
||||||
sources: [evidenced-by, derived-from, adapted-from, defined-in, see-also]
|
sources: [evidenced-by, derived-from, adapted-from, defined-in, see-also]
|
||||||
comparisons: [compares-with, see-also]
|
comparisons: [compares-with, see-also]
|
||||||
required_by_stack: false
|
required_by_stack: false
|
||||||
@@ -27,19 +27,27 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
|
|||||||
|
|
||||||
`concept` (`tools/wikitool types describe concept`).
|
`concept` (`tools/wikitool types describe concept`).
|
||||||
|
|
||||||
## Decisions and ADRs
|
## Decisions
|
||||||
|
|
||||||
An architectural decision is a concept page, prefixed as
|
An architectural decision is an ordinary concept page with `concept_type: decision`
|
||||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records:
|
(`tools/wikitool types describe concept`) - not a separate format, and not a separate location.
|
||||||
|
There is no `adr-NNN-`-prefixed filename and no dedicated directory: naming follows
|
||||||
|
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) like every other concept, and the page
|
||||||
|
lives in `kb/concepts/` like every other concept.
|
||||||
|
|
||||||
- **Context** - what forced a decision.
|
The body is organic prose under this collection's usual sections, not a fixed template. What it
|
||||||
- **Decision** - what was chosen.
|
still has to carry: what was decided, what forced the decision, what it costs (not only what it
|
||||||
- **Consequences** - what this costs, not only what it buys.
|
buys), and a link to every entity the decision affects. A `**Status:**` line is optional - most
|
||||||
- **Status** - proposed / accepted / deprecated / superseded.
|
decision pages in this instance carry none, because the page's own prose already says whether the
|
||||||
- Links to every entity the decision affects.
|
decision stands.
|
||||||
|
|
||||||
A superseded ADR is never deleted or rewritten. The new one declares `supersedes` pointing at
|
A decision superseded by a later one is never deleted or rewritten. The new page declares
|
||||||
it; the old one needs no edge back, because its inbound view renders the replacement.
|
`supersedes` pointing at it; the old one needs no edge back, because its inbound view renders the
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
`concept_type: decision` is also the one subtype [kb/CONTRACT.md](../CONTRACT.md)'s confidence
|
||||||
|
machinery treats differently: `confidence decay` skips it structurally, because elapsed time does
|
||||||
|
not falsify a decision - only a later decision superseding it does.
|
||||||
|
|
||||||
## Authorised labels
|
## Authorised labels
|
||||||
|
|
||||||
@@ -50,6 +58,11 @@ nothing on its own.
|
|||||||
|
|
||||||
The widest authorisation in this instance, because argumentation is what concept pages do. Note that the operational labels are absent: a concept does not `depend-on` anything - the entity implementing it does.
|
The widest authorisation in this instance, because argumentation is what concept pages do. Note that the operational labels are absent: a concept does not `depend-on` anything - the entity implementing it does.
|
||||||
|
|
||||||
|
`addresses` is the one that pairs with this collection's own subtypes: a `concept_type: decision`
|
||||||
|
or a mechanism pointing at the `concept_type: problem` it answers. Without it, the collection can
|
||||||
|
declare a problem and never say what was done about it. `alternative-to` is self-dual and written
|
||||||
|
once per pair - see [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md).
|
||||||
|
|
||||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||||
|
|
||||||
## Outbound linking
|
## Outbound linking
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Consolidation Tiers
|
- part-of: Consolidation Tiers
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -41,5 +41,5 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Consolidation Tiers]]
|
- **part-of:** [[Consolidation Tiers]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
| [[Hybrid Search]] | architecture | Multimodale Suche, die BM25-Schlüsselwortabgleich, Vektor-Embeddings und Graph Traversal verbindet, um Wissensabruf im Wiki skalierbar zu machen. | 2026-08-29 |
|
| [[Hybrid Search]] | architecture | Multimodale Suche, die BM25-Schlüsselwortabgleich, Vektor-Embeddings und Graph Traversal verbindet, um Wissensabruf im Wiki skalierbar zu machen. | 2026-08-29 |
|
||||||
| [[Implementation Spectrum]] | architecture | Modularer Einführungspfad für die Funktionen von LLM Wiki v2, vom minimal tragfähigen Wiki bis zur vollen Umsetzung mit Automatisierung und Governance. | 2026-08-29 |
|
| [[Implementation Spectrum]] | architecture | Modularer Einführungspfad für die Funktionen von LLM Wiki v2, vom minimal tragfähigen Wiki bis zur vollen Umsetzung mit Automatisierung und Governance. | 2026-08-29 |
|
||||||
| [[Index Scaling]] | workflow | Skalierungsregeln für Indexseiten: Tabellenabschnitte ab 50 Einträgen teilen, ab 200 Seiten _meta/topic-map.md anlegen | 2026-08-29 |
|
| [[Index Scaling]] | workflow | Skalierungsregeln für Indexseiten: Tabellenabschnitte ab 50 Einträgen teilen, ab 200 Seiten _meta/topic-map.md anlegen | 2026-08-29 |
|
||||||
| [[Issue Label Scheme]] | decision | Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und size/XS..L, bewusst keine dritte Achse; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf | 2026-08-31 |
|
| [[Issue Label Scheme]] | decision | Pflicht-Labelschema fuer das Gitea-Board: seit 2026-09-02 vier Achsen (area/kind/prio/size) plus zwei optionale status/-Flags, dazu der Issue-Body als aktuelle Wahrheit; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf | 2026-09-02 |
|
||||||
| [[Iteration and Cost Limits]] | workflow | Im Code durchgesetzte Obergrenze von 60 wikitool-Aufrufen je Session, Loop-Breaker bei 3 identischen Wiederholungen, Slot-Erstattung, ein gemessenes Kalibrierungsband, und Retrieval sowie der MCP-Leseserver bleiben ausgenommen | 2026-09-02 |
|
| [[Iteration and Cost Limits]] | workflow | Im Code durchgesetzte Obergrenze von 60 wikitool-Aufrufen je Session, Loop-Breaker bei 3 identischen Wiederholungen, Slot-Erstattung, ein gemessenes Kalibrierungsband, und Retrieval sowie der MCP-Leseserver bleiben ausgenommen | 2026-09-02 |
|
||||||
| [[KB Migration]] | workflow | Migration des KB-Inhalts entlang einer geordneten Versionskette; abgegrenzt gegen offene Instanz-Aktionen, die in den doctor-Check gehoeren statt in die Kette | 2026-08-31 |
|
| [[KB Migration]] | workflow | Migration des KB-Inhalts entlang einer geordneten Versionskette; abgegrenzt gegen offene Instanz-Aktionen, die in den doctor-Check gehoeren statt in die Kette | 2026-08-31 |
|
||||||
| [[KB Stack Versioning]] | decision | Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet (Drop-in-Ersatz) und Inhaltsmigration sind seit 2.5.0 getrennte, unabhaengig geprueft Fragen | 2026-09-02 |
|
| [[KB Stack Versioning]] | decision | Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet (Drop-in-Ersatz) und Inhaltsmigration sind seit 2.5.0 getrennte, unabhaengig geprueft Fragen | 2026-09-02 |
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ type: types/concept.md
|
|||||||
concept_type: decision
|
concept_type: decision
|
||||||
tags: [issues, gitea, triage, labels, backlog]
|
tags: [issues, gitea, triage, labels, backlog]
|
||||||
created: 2026-08-31
|
created: 2026-08-31
|
||||||
modified: 2026-08-31
|
modified: 2026-09-02
|
||||||
related:
|
related:
|
||||||
- operates-on: Chemenu
|
- operates-on: Chemenu
|
||||||
- mechanism: Gitea MCP Server
|
- mechanism: Gitea MCP Server
|
||||||
- see-also: KB Stack Versioning
|
- see-also: KB Stack Versioning
|
||||||
- see-also: Detect-Repair Asymmetry
|
- see-also: Detect-Repair Asymmetry
|
||||||
sources: [Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]
|
sources: [Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||||
confidence: 0.70
|
confidence: 0.70
|
||||||
confidence_base: 0.70
|
confidence_base: 0.85
|
||||||
provenance: sourced
|
provenance: sourced
|
||||||
summary: 'Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und size/XS..L, bewusst keine dritte Achse; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf'
|
summary: 'Pflicht-Labelschema fuer das Gitea-Board: seit 2026-09-02 vier Achsen (area/kind/prio/size) plus zwei optionale status/-Flags, dazu der Issue-Body als aktuelle Wahrheit; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf'
|
||||||
---
|
---
|
||||||
# Issue Label Scheme
|
# Issue Label Scheme
|
||||||
|
|
||||||
@@ -22,36 +22,78 @@ summary: 'Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und s
|
|||||||
## Definition
|
## Definition
|
||||||
|
|
||||||
Issue Label Scheme ist die Entscheidung, offene Arbeit an diesem Stack ausschließlich als
|
Issue Label Scheme ist die Entscheidung, offene Arbeit an diesem Stack ausschließlich als
|
||||||
Gitea-Issues zu führen und jedes Issue mit genau zwei Pflicht-Labels zu versehen: einer
|
Gitea-Issues zu führen und jedes offene Issue mit vier Pflicht-Labels zu versehen: einem
|
||||||
Priorität `prio/1..3` und einer Größe `size/XS..L`. Eine dritte Achse gibt es bewusst nicht.
|
Bereich `area/`, einer Art `kind/`, einer Priorität `prio/` und einer Größe `size/`. Dazu
|
||||||
Getroffen wurde die Entscheidung am 2026-08-31, gemeinsam mit der Löschung von `TODO.md`[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
kommen zwei optionale `status/`-Flags. Getroffen wurde die Entscheidung in dieser Form am
|
||||||
|
2026-09-02[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02]; sie ersetzt das
|
||||||
|
zweiachsige Schema vom 2026-08-31 (siehe [Historie](#historie)).
|
||||||
|
|
||||||
| Priorität | Bedeutung |
|
| `area/` | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `prio/1` | Blockiert oder beschädigt laufende Arbeit. Als Nächstes. |
|
| `area/kb` | `kb/`-Schema, Contract, Confidence, Lint - die Wissensbasis als System. |
|
||||||
| `prio/2` | Sammelt Zinsen. Eingeplant. |
|
| `area/distribution` | Auslieferung, Upgrade und Versionierung einer Instanz. |
|
||||||
| `prio/3` | Lohnend, wartet auf einen benannten Auslöser. |
|
| `area/corpus` | Inhalt und Umfang von `kb/` in dieser Instanz, samt Demo-/Testbett-Frage. |
|
||||||
|
| `area/workflow` | Git, Merge, Branching, Publish, PRs. |
|
||||||
|
| `area/process` | Der Entwicklungsprozess selbst, nicht der Stack als Artefakt. |
|
||||||
|
|
||||||
| Größe | Bedeutung |
|
| `kind/` | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `kind/decision` | Wartet auf eine Betreiberentscheidung. |
|
||||||
|
| `kind/build` | Spezifiziert, wartet nur noch auf Umsetzungszeit. |
|
||||||
|
| `kind/defect` | Befund: Doku und Realität, oder zwei Dokus, widersprechen sich. |
|
||||||
|
|
||||||
|
| `prio/` | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `prio/blocking` | Blockiert oder beschädigt laufende Arbeit. Als Nächstes. |
|
||||||
|
| `prio/planned` | Sammelt Zinsen. Eingeplant. |
|
||||||
|
| `prio/waiting` | Lohnend, wartet auf einen benannten Auslöser. |
|
||||||
|
|
||||||
|
| `size/` | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `size/XS` | Minuten. Oft nur eine Entscheidung oder eine Beobachtung. |
|
|
||||||
| `size/S` | Eine Sitzung, ein Publish, ein klarer Schnitt. |
|
| `size/S` | Eine Sitzung, ein Publish, ein klarer Schnitt. |
|
||||||
| `size/M` | Mehrere Dateien; eine Contract- oder Instruction-Änderung; eigener Testaufwand. |
|
| `size/M` | Mehrere Dateien; eine Contract- oder Instruction-Änderung; eigener Testaufwand. |
|
||||||
| `size/L` | Mehrere Sitzungen, oder offene Entwurfsfragen vor dem ersten Commit. |
|
| `size/L` | Mehrere Sitzungen, oder offene Entwurfsfragen vor dem ersten Commit. |
|
||||||
|
|
||||||
Die sieben Labels wurden angelegt und auf alle zehn zu dem Zeitpunkt offenen Issues
|
| `status/` (optional) | Bedeutung |
|
||||||
angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
|---|---|
|
||||||
|
| `status/blocked` | Wartet auf ein anderes, noch offenes Issue - unabhängig vom `prio`-Wert nicht eigenständig bearbeitbar. |
|
||||||
|
| `status/unconfirmed` | Gemeldeter Verdacht, noch nicht gegen tatsächliches Verhalten geprüft; `size` und `prio` sind solange vorläufig. |
|
||||||
|
|
||||||
|
Sechzehn Labels stehen in Gitea; `prio/1`, `prio/2`, `prio/3` und `size/XS` existieren nicht
|
||||||
|
mehr[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
|
||||||
## Kernpunkte
|
## Kernpunkte
|
||||||
|
|
||||||
- **Beide Achsen sind Pflicht, weil eine Priorität ohne Kosten eine halbe Entscheidung ist.**
|
- **Vier Achsen sind Pflicht, weil ihre Pflege maschinell läuft.** Der ursprüngliche Einwand
|
||||||
Größe ist Aufwand und nicht Wichtigkeit, deshalb ist `prio/1 size/XS` das Beste, was auf
|
gegen eine dritte Achse war der Aufwand für einen einzelnen menschlichen Betreuer. Da
|
||||||
einem Board stehen kann, und `prio/3 size/L` etwas, worüber gesprochen wird, bevor jemand
|
Body-Rewrites und Labelpflege über eine LLM-Sitzung laufen und ein Mensch in der Regel nur
|
||||||
anfängt.
|
Metadaten anfasst, trägt dieser Einwand
|
||||||
- **`prio/3` ist kein Friedhof.** Der Auslöser muss im Issue benannt sein, sonst ist das Label
|
nicht mehr[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
ein höfliches Nein[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
- **Der Issue-Body ist die aktuelle Wahrheit, nicht der Ursprungstext.** Die Umsetzung eines
|
||||||
- **Keine dritte Achse.** Art, Bereich oder Status wurden verworfen als der Punkt, ab dem eine
|
Issues zieht sich über mehrere, zeitlich getrennte Sitzungen, und der Body ist das einzige,
|
||||||
Taxonomie eigene Pflege braucht. Das Board hat einen einzigen Betreuer.
|
was sie verbindet: eine Sitzung muss allein aus ihm rekonstruieren können, was entschieden
|
||||||
|
und was offen ist. Er wird deshalb umgeschrieben statt
|
||||||
|
ergänzt[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
- **Ein Kommentar ist ein Changelog, keine Kopie.** Ein Volltext-Snapshot des alten Bodys pro
|
||||||
|
Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare
|
||||||
|
Historie, sondern nur eine weitere
|
||||||
|
Kopie[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
- **`area/` folgt der Systemgrenze, nicht dem Codeort.** Die Werte folgen der Stufenteilung aus
|
||||||
|
`AGENTS.md`. Ein `area/tools` gibt es bewusst nicht - Tooling wird nach der Domäne
|
||||||
|
einsortiert, die es
|
||||||
|
bedient[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
- **`kind/` darf sich im Lauf eines Issues ändern.** Der Wechsel von `decision` zu `build`,
|
||||||
|
sobald entschieden ist, ist erwünschtes Session-Memory-Verhalten und kein
|
||||||
|
Makel[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
- **Eine Priorität ohne Kosten ist eine halbe Entscheidung.** Größe ist Aufwand und nicht
|
||||||
|
Wichtigkeit, deshalb ist `prio/blocking size/S` das Beste, was auf einem Board stehen kann,
|
||||||
|
und `prio/waiting size/L` etwas, worüber gesprochen wird, bevor jemand anfängt.
|
||||||
|
- **`prio/waiting` ist kein Friedhof.** Der Auslöser muss im Issue benannt sein, sonst ist das
|
||||||
|
Label ein höfliches Nein[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||||
|
- **Kein unbelegter Verdacht bleibt offen liegen.** Die Triage eines `status/unconfirmed`
|
||||||
|
endet entweder mit entferntem Flag und verbindlichen `size`/`prio`-Werten oder mit einem
|
||||||
|
geschlossenen Issue samt Begründung - die Prozessentsprechung zu Invariante 3 des
|
||||||
|
Stacks[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
- **Priorisiert wird nach Schaden, nicht nach Aufwand.** Das Kriterium der ersten Triage
|
- **Priorisiert wird nach Schaden, nicht nach Aufwand.** Das Kriterium der ersten Triage
|
||||||
lautete: was blockiert oder beschädigt laufende Arbeit. Ein Werkzeugfehler, der seinen
|
lautete: was blockiert oder beschädigt laufende Arbeit. Ein Werkzeugfehler, der seinen
|
||||||
Benutzer gegen eine Invariante des Stacks drückt, rangiert deshalb vor einer fehlenden
|
Benutzer gegen eine Invariante des Stacks drückt, rangiert deshalb vor einer fehlenden
|
||||||
@@ -63,6 +105,28 @@ angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-0
|
|||||||
Linkliste auf Issues; der zweite, die Recherche-Notiz, ging vollständig nach #15. Danach gab
|
Linkliste auf Issues; der zweite, die Recherche-Notiz, ging vollständig nach #15. Danach gab
|
||||||
es nichts mehr in der Datei, was nicht auf Gitea stand.
|
es nichts mehr in der Datei, was nicht auf Gitea stand.
|
||||||
|
|
||||||
|
## Historie
|
||||||
|
|
||||||
|
Das ursprüngliche Schema vom 2026-08-31 hatte ~~genau zwei Pflicht-Labels, `prio/1..3` und
|
||||||
|
`size/XS..L`, und verzichtete ausdrücklich auf eine dritte Achse: Art, Bereich oder Status
|
||||||
|
wurden verworfen als der Punkt, ab dem eine Taxonomie eigene Pflege braucht, und das Board
|
||||||
|
habe einen einzigen Betreuer.~~ Sieben Labels wurden angelegt und auf alle zehn zu dem
|
||||||
|
Zeitpunkt offenen Issues
|
||||||
|
angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||||
|
|
||||||
|
Was sich am 2026-09-02 geändert hat:
|
||||||
|
|
||||||
|
| Achse | Vorher | Jetzt |
|
||||||
|
|---|---|---|
|
||||||
|
| `prio/` | `1`, `2`, `3` | `blocking`, `planned`, `waiting` - reine Umbenennung, Bedeutung unverändert |
|
||||||
|
| `size/` | `XS`, `S`, `M`, `L` | `S`, `M`, `L` - `XS` entfällt, die übrigen unverändert |
|
||||||
|
| `area/` | - | fünf Werte, neu |
|
||||||
|
| `kind/` | - | drei Werte, neu |
|
||||||
|
| `status/` | - | zwei optionale Flags, neu |
|
||||||
|
|
||||||
|
Der Verzicht auf die dritte Achse fiel damit weg, nicht weil die Begründung falsch war,
|
||||||
|
sondern weil ihre Voraussetzung entfallen ist: gepflegt wird das Board nicht mehr von Hand.
|
||||||
|
|
||||||
## Wo die Regel liegt
|
## Wo die Regel liegt
|
||||||
|
|
||||||
Die Platzierung war die tragende Entscheidung, nicht das Schema selbst. `README.md` und
|
Die Platzierung war die tragende Entscheidung, nicht das Schema selbst. `README.md` und
|
||||||
@@ -80,42 +144,42 @@ Instanz ändert sich nichts. Das CI-Versions-Gate verlangte den Bump trotzdem, w
|
|||||||
auf `instructions/` passt und `instructions/dev/` darunter liegt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]. Siehe
|
auf `instructions/` passt und `instructions/dev/` darunter liegt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]. Siehe
|
||||||
[[KB Stack Versioning]].
|
[[KB Stack Versioning]].
|
||||||
|
|
||||||
|
Für die Erweiterung auf vier Achsen galt dieselbe Rechnung noch einmal: sie ging als `4.0.1`
|
||||||
|
und damit ebenfalls als PATCH
|
||||||
|
hinaus[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||||
|
|
||||||
## Beispiele
|
## Beispiele
|
||||||
|
|
||||||
- [[Chemenu]] - das Repository, dessen Board nach dem Schema geführt wird; sieben Labels
|
- [[Chemenu]] - das Repository, dessen Board nach dem Schema geführt wird; sechzehn Labels
|
||||||
wurden angelegt und auf alle zehn offenen Issues angewandt
|
stehen dort, verteilt auf vier Pflicht- und eine optionale Familie
|
||||||
- [[Gitea MCP Server]] - der Weg, auf dem Issues und Labels gelesen und geschrieben werden, da
|
- [[Gitea MCP Server]] - der Weg, auf dem Issues und Labels gelesen und geschrieben werden
|
||||||
das Origin-Repository privat ist
|
|
||||||
- [[Detect-Repair Asymmetry]] - Issue #14 ist der Fall, den dieses Concept beschreibt, und
|
- [[Detect-Repair Asymmetry]] - Issue #14 ist der Fall, den dieses Concept beschreibt, und
|
||||||
trägt `prio/2 size/S`
|
trug in der ersten Triage `prio/2 size/S`, nach der Umbenennung also `prio/planned size/S`
|
||||||
|
|
||||||
## Wann zu verwenden
|
## Wann zu verwenden
|
||||||
|
|
||||||
- Auf einem Board mit einem einzigen Betreuer, das eine erkennbare Reihenfolge braucht, aber
|
- Auf einem Board mit einem einzigen menschlichen Betreuer, dessen Labelpflege maschinell
|
||||||
keinen Prozess.
|
läuft. Erst das macht mehr als zwei Achsen bezahlbar.
|
||||||
- Sobald offene Arbeit sonst in Prosa-Dateien wandert, die niemand als Board liest und die
|
- Sobald offene Arbeit sonst in Prosa-Dateien wandert, die niemand als Board liest und die
|
||||||
gegen den Tracker driften.
|
gegen den Tracker driften.
|
||||||
|
- Sobald die Bearbeitung eines Issues sich über mehrere, zeitlich getrennte Sitzungen zieht -
|
||||||
|
dann trägt die Body-als-Wahrheit-Konvention den Kontext, den sonst ein Mensch jedes Mal neu
|
||||||
|
erzählen müsste.
|
||||||
|
|
||||||
## Wann NICHT zu verwenden
|
## Wann NICHT zu verwenden
|
||||||
|
|
||||||
- Nicht auf einem Board mit mehreren Teams, wo Zuständigkeit und Bereich echte Information
|
- Nicht dort, wo Labels von Hand gepflegt werden. Dann ist die ursprüngliche Zweiachsigkeit
|
||||||
tragen. Dann ist die dritte Achse keine Taxonomie-Pflege, sondern Routing.
|
die tragfähigere Wahl, und die Begründung von 2026-08-31 gilt unverändert.
|
||||||
- Nicht als Ersatz für die Abnahmekriterien im Issue-Text. Die Labels ordnen ein Issue ein; ob
|
- Nicht als Ersatz für die Abnahmekriterien im Issue-Text. Die Labels ordnen ein Issue ein; ob
|
||||||
es fertig ist, sagen sie nicht.
|
es fertig ist, sagen sie nicht.
|
||||||
|
- Nicht mit umgeschriebenen Bodys dort, wo mehrere Menschen denselben Thread lesen und den
|
||||||
|
Verlauf brauchen. Die Konvention tauscht Historie gegen Aktualität und setzt voraus, dass
|
||||||
|
der Changelog-Kommentar als Historie genügt.
|
||||||
- Nicht in einer ausgelieferten Instanz. Das Schema beschreibt das Entwicklungs-Repository und
|
- Nicht in einer ausgelieferten Instanz. Das Schema beschreibt das Entwicklungs-Repository und
|
||||||
hat außerhalb davon keinen Gegenstand.
|
hat außerhalb davon keinen Gegenstand.
|
||||||
|
|
||||||
## Beziehungen
|
|
||||||
|
|
||||||
## Siehe auch
|
|
||||||
|
|
||||||
- [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
|
||||||
|
|
||||||
## Fußnoten
|
|
||||||
|
|
||||||
[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]: [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
|
||||||
|
|
||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
|
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **operates-on:** [[Chemenu]]
|
- **operates-on:** [[Chemenu]]
|
||||||
@@ -123,3 +187,10 @@ auf `instructions/` passt und `instructions/dev/` darunter liegt[^s-conversation
|
|||||||
- **see-also:** [[KB Stack Versioning]]
|
- **see-also:** [[KB Stack Versioning]]
|
||||||
- **see-also:** [[Detect-Repair Asymmetry]]
|
- **see-also:** [[Detect-Repair Asymmetry]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|
||||||
|
<!-- wikitool:footnotes -->
|
||||||
|
## Fußnoten
|
||||||
|
|
||||||
|
[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02]: [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]]
|
||||||
|
[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]: [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
||||||
|
<!-- /wikitool:footnotes -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: [memory, lifecycle, confidence, knowledge-management]
|
|||||||
created: 2026-07-26
|
created: 2026-07-26
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: LLM Wiki Pattern
|
- part-of: LLM Wiki Pattern
|
||||||
- see-also: Confidence Scoring
|
- see-also: Confidence Scoring
|
||||||
- composition: Supersession
|
- composition: Supersession
|
||||||
- see-also: Consolidation Tiers
|
- see-also: Consolidation Tiers
|
||||||
@@ -126,7 +126,7 @@ Basierend auf [[Agent Memory]]-Erfahrung:
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[LLM Wiki Pattern]]
|
- **part-of:** [[LLM Wiki Pattern]]
|
||||||
- **see-also:** [[Confidence Scoring]]
|
- **see-also:** [[Confidence Scoring]]
|
||||||
- **composition:** [[Supersession]]
|
- **composition:** [[Supersession]]
|
||||||
- **see-also:** [[Consolidation Tiers]]
|
- **see-also:** [[Consolidation Tiers]]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
|||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- exemplifies: Implementation Spectrum
|
- exemplifies: Implementation Spectrum
|
||||||
- see-also: Multi-Agent Collaboration
|
- part-of: Multi-Agent Collaboration
|
||||||
- evidenced-by: Source - LLM Wiki v2
|
- evidenced-by: Source - LLM Wiki v2
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
@@ -46,6 +46,6 @@ TODO
|
|||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **exemplifies:** [[Implementation Spectrum]]
|
- **exemplifies:** [[Implementation Spectrum]]
|
||||||
- **see-also:** [[Multi-Agent Collaboration]]
|
- **part-of:** [[Multi-Agent Collaboration]]
|
||||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Consolidation Tiers
|
- part-of: Consolidation Tiers
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -43,5 +43,5 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Consolidation Tiers]]
|
- **part-of:** [[Consolidation Tiers]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Hybrid Search
|
- part-of: Hybrid Search
|
||||||
- exemplifies: LLM Wiki Pattern
|
- exemplifies: LLM Wiki Pattern
|
||||||
- evidenced-by: Source - LLM Wiki v2
|
- evidenced-by: Source - LLM Wiki v2
|
||||||
sources: []
|
sources: []
|
||||||
@@ -45,7 +45,7 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Hybrid Search]]
|
- **part-of:** [[Hybrid Search]]
|
||||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Consolidation Tiers
|
- part-of: Consolidation Tiers
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -43,5 +43,5 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Consolidation Tiers]]
|
- **part-of:** [[Consolidation Tiers]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Multi-Agent Collaboration
|
- part-of: Multi-Agent Collaboration
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -43,5 +43,5 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Multi-Agent Collaboration]]
|
- **part-of:** [[Multi-Agent Collaboration]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: [split, threshold, lines, pages]
|
|||||||
created: 2026-08-03
|
created: 2026-08-03
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Content Quality Control
|
- part-of: Content Quality Control
|
||||||
- see-also: Stub Threshold
|
- see-also: Stub Threshold
|
||||||
- see-also: Index Scaling
|
- see-also: Index Scaling
|
||||||
sources: [Source - LLM Improvements Sonnet Analysis]
|
sources: [Source - LLM Improvements Sonnet Analysis]
|
||||||
@@ -69,7 +69,7 @@ Split Threshold definiert die maximale Größe, die eine Wiki-Seite erreichen so
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Content Quality Control]]
|
- **part-of:** [[Content Quality Control]]
|
||||||
- **see-also:** [[Stub Threshold]]
|
- **see-also:** [[Stub Threshold]]
|
||||||
- **see-also:** [[Index Scaling]]
|
- **see-also:** [[Index Scaling]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: [stub, minimum, quality, lines]
|
|||||||
created: 2026-08-03
|
created: 2026-08-03
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Content Quality Control
|
- part-of: Content Quality Control
|
||||||
- see-also: Split Threshold
|
- see-also: Split Threshold
|
||||||
- see-also: Semantic Lint Automation
|
- see-also: Semantic Lint Automation
|
||||||
sources: [Source - LLM Improvements Sonnet Analysis, Source - LLM Wiki v2]
|
sources: [Source - LLM Improvements Sonnet Analysis, Source - LLM Wiki v2]
|
||||||
@@ -80,7 +80,7 @@ It provides comprehensive information about the topic. It clearly exceeds the st
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Content Quality Control]]
|
- **part-of:** [[Content Quality Control]]
|
||||||
- **see-also:** [[Split Threshold]]
|
- **see-also:** [[Split Threshold]]
|
||||||
- **see-also:** [[Semantic Lint Automation]]
|
- **see-also:** [[Semantic Lint Automation]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: [versioning, knowledge, updates, lifecycle]
|
|||||||
created: 2026-07-26
|
created: 2026-07-26
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Memory Lifecycle
|
- part-of: Memory Lifecycle
|
||||||
- rests-on: Confidence Scoring
|
- rests-on: Confidence Scoring
|
||||||
- see-also: Knowledge Graph
|
- see-also: Knowledge Graph
|
||||||
- exemplifies: LLM Wiki Pattern
|
- exemplifies: LLM Wiki Pattern
|
||||||
@@ -138,7 +138,7 @@ Wenn Aussage B Aussage A ersetzt:
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Memory Lifecycle]]
|
- **part-of:** [[Memory Lifecycle]]
|
||||||
- **rests-on:** [[Confidence Scoring]]
|
- **rests-on:** [[Confidence Scoring]]
|
||||||
- **see-also:** [[Knowledge Graph]]
|
- **see-also:** [[Knowledge Graph]]
|
||||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
|||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- exemplifies: Implementation Spectrum
|
- exemplifies: Implementation Spectrum
|
||||||
- see-also: Knowledge Graph
|
- part-of: Knowledge Graph
|
||||||
- evidenced-by: Source - LLM Wiki v2
|
- evidenced-by: Source - LLM Wiki v2
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
@@ -46,6 +46,6 @@ TODO
|
|||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **exemplifies:** [[Implementation Spectrum]]
|
- **exemplifies:** [[Implementation Spectrum]]
|
||||||
- **see-also:** [[Knowledge Graph]]
|
- **part-of:** [[Knowledge Graph]]
|
||||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Hybrid Search
|
- part-of: Hybrid Search
|
||||||
- exemplifies: LLM Wiki Pattern
|
- exemplifies: LLM Wiki Pattern
|
||||||
- evidenced-by: Source - LLM Wiki v2
|
- evidenced-by: Source - LLM Wiki v2
|
||||||
sources: []
|
sources: []
|
||||||
@@ -45,7 +45,7 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Hybrid Search]]
|
- **part-of:** [[Hybrid Search]]
|
||||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
|||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- exemplifies: Implementation Spectrum
|
- exemplifies: Implementation Spectrum
|
||||||
- see-also: Multi-Agent Collaboration
|
- part-of: Multi-Agent Collaboration
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -45,5 +45,5 @@ TODO
|
|||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **exemplifies:** [[Implementation Spectrum]]
|
- **exemplifies:** [[Implementation Spectrum]]
|
||||||
- **see-also:** [[Multi-Agent Collaboration]]
|
- **part-of:** [[Multi-Agent Collaboration]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: []
|
|||||||
created: 2026-08-02
|
created: 2026-08-02
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: Consolidation Tiers
|
- part-of: Consolidation Tiers
|
||||||
sources: []
|
sources: []
|
||||||
confidence: 0.50
|
confidence: 0.50
|
||||||
confidence_base: 0.50
|
confidence_base: 0.50
|
||||||
@@ -43,5 +43,5 @@ TODO
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[Consolidation Tiers]]
|
- **part-of:** [[Consolidation Tiers]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
profile: entities
|
profile: entities
|
||||||
outbound:
|
outbound:
|
||||||
entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, part-of, composition, supersedes, see-also]
|
entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, authored, alternative-to, implements, part-of, composition, supersedes, derived-from, adapted-from, see-also]
|
||||||
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, see-also]
|
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also]
|
||||||
sources: [evidenced-by, defined-in, see-also]
|
sources: [evidenced-by, defined-in, see-also]
|
||||||
comparisons: [compares-with, see-also]
|
comparisons: [compares-with, see-also]
|
||||||
required_by_stack: false
|
required_by_stack: false
|
||||||
@@ -57,6 +57,14 @@ nothing on its own.
|
|||||||
|
|
||||||
Operational labels dominate here because an entity's relationships are mostly to other concrete things. `implements` points *out* to a concept; the concept does not point back unless that direction is a statement of its own.
|
Operational labels dominate here because an entity's relationships are mostly to other concrete things. `implements` points *out* to a concept; the concept does not point back unless that direction is a statement of its own.
|
||||||
|
|
||||||
|
Three of these carry a caveat this area produces more often than the others. `authored` belongs
|
||||||
|
on a person page pointing at what they made, and it is the label a dead or departed creator
|
||||||
|
takes - `owns` claims someone answers for the thing *now*. `alternative-to` is self-dual and is
|
||||||
|
written **once per pair**, never from both ends: a set of interchangeable tools is where a
|
||||||
|
mirrored clique grows fastest. `derived-from` and `adapted-from` are here for the fork and the
|
||||||
|
re-implementation - one tool worked up out of another - which is a lineage claim the operational
|
||||||
|
labels cannot make.
|
||||||
|
|
||||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||||
|
|
||||||
## Outbound linking
|
## Outbound linking
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ tags: [researcher, ai, machine-learning, open-source]
|
|||||||
created: 2026-07-26
|
created: 2026-07-26
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: LLM Wiki Pattern
|
- authored: LLM Wiki Pattern
|
||||||
- see-also: Three-Layer Architecture
|
- authored: Three-Layer Architecture
|
||||||
- see-also: Knowledge Compounding
|
- authored: Knowledge Compounding
|
||||||
sources: [Source - LLM Wiki v2, Source - LLM Wiki Pattern]
|
sources: [Source - LLM Wiki v2, Source - LLM Wiki Pattern]
|
||||||
confidence: 0.95
|
confidence: 0.95
|
||||||
confidence_base: 0.95
|
confidence_base: 0.95
|
||||||
@@ -42,7 +42,7 @@ Seine ursprüngliche Einsicht - "stop re-deriving, start compiling" - bildet die
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[LLM Wiki Pattern]]
|
- **authored:** [[LLM Wiki Pattern]]
|
||||||
- **see-also:** [[Three-Layer Architecture]]
|
- **authored:** [[Three-Layer Architecture]]
|
||||||
- **see-also:** [[Knowledge Compounding]]
|
- **authored:** [[Knowledge Compounding]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ created: 2026-07-26
|
|||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: LLM Wiki Pattern
|
- see-also: LLM Wiki Pattern
|
||||||
- owns: Memex
|
- authored: Memex
|
||||||
sources: [Source - LLM Wiki Pattern]
|
sources: [Source - LLM Wiki Pattern]
|
||||||
confidence: 0.90
|
confidence: 0.90
|
||||||
confidence_base: 0.90
|
confidence_base: 0.90
|
||||||
@@ -72,5 +72,5 @@ Laut dem Artikel [[LLM Wiki Pattern]] war Bushs Memex-Vision:
|
|||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[LLM Wiki Pattern]]
|
- **see-also:** [[LLM Wiki Pattern]]
|
||||||
- **owns:** [[Memex]]
|
- **authored:** [[Memex]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ related:
|
|||||||
- implements: MCP-Leseserver
|
- implements: MCP-Leseserver
|
||||||
- uses: wikitool
|
- uses: wikitool
|
||||||
- composition: AGENTS.md
|
- composition: AGENTS.md
|
||||||
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]
|
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||||
confidence: 0.90
|
confidence: 0.90
|
||||||
confidence_base: 0.90
|
confidence_base: 0.90
|
||||||
provenance: mixed
|
provenance: mixed
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ modified: 2026-08-29
|
|||||||
related:
|
related:
|
||||||
- depends-on: Wine
|
- depends-on: Wine
|
||||||
- see-also: Proton
|
- see-also: Proton
|
||||||
- see-also: Wine GE
|
- part-of: Wine GE
|
||||||
- see-also: Arch Linux
|
- see-also: Arch Linux
|
||||||
sources: [Source - Wine]
|
sources: [Source - Wine]
|
||||||
confidence: 0.85
|
confidence: 0.85
|
||||||
@@ -68,6 +68,6 @@ Wine-Staging-Patches enthalten typischerweise:
|
|||||||
|
|
||||||
- **depends-on:** [[Wine]]
|
- **depends-on:** [[Wine]]
|
||||||
- **see-also:** [[Proton]]
|
- **see-also:** [[Proton]]
|
||||||
- **see-also:** [[Wine GE]]
|
- **part-of:** [[Wine GE]]
|
||||||
- **see-also:** [[Arch Linux]]
|
- **see-also:** [[Arch Linux]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ related:
|
|||||||
- implements: Issue Label Scheme
|
- implements: Issue Label Scheme
|
||||||
- uses: Gitea
|
- uses: Gitea
|
||||||
- uses: Gitea Actions
|
- uses: Gitea Actions
|
||||||
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]
|
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||||
confidence: 0.70
|
confidence: 0.70
|
||||||
confidence_base: 0.70
|
confidence_base: 0.70
|
||||||
provenance: sourced
|
provenance: sourced
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags: [schema, taxonomy, external, farzaa-gist]
|
|||||||
created: 2026-08-03
|
created: 2026-08-03
|
||||||
modified: 2026-08-29
|
modified: 2026-08-29
|
||||||
related:
|
related:
|
||||||
- see-also: farzaa gist
|
- part-of: farzaa gist
|
||||||
- see-also: AGENTS.md
|
- see-also: AGENTS.md
|
||||||
- evidenced-by: Source - LLM Improvements Sonnet Analysis
|
- evidenced-by: Source - LLM Improvements Sonnet Analysis
|
||||||
sources: [Source - LLM Improvements Sonnet Analysis]
|
sources: [Source - LLM Improvements Sonnet Analysis]
|
||||||
@@ -79,7 +79,7 @@ Dies sind handlungsfähige Empfehlungen, die in der Sonnet-Analyse als wertvoll
|
|||||||
<!-- wikitool:links -->
|
<!-- wikitool:links -->
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
- **see-also:** [[farzaa gist]]
|
- **part-of:** [[farzaa gist]]
|
||||||
- **see-also:** [[AGENTS.md]]
|
- **see-also:** [[AGENTS.md]]
|
||||||
- **evidenced-by:** [[Source - LLM Improvements Sonnet Analysis]]
|
- **evidenced-by:** [[Source - LLM Improvements Sonnet Analysis]]
|
||||||
<!-- /wikitool:links -->
|
<!-- /wikitool:links -->
|
||||||
|
|||||||
+4
-4
@@ -13,12 +13,12 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
|||||||
|
|
||||||
## Statistics
|
## Statistics
|
||||||
|
|
||||||
- **Total Pages:** 180
|
- **Total Pages:** 181
|
||||||
- **Comparisons:** 1
|
- **Comparisons:** 1
|
||||||
- **Concepts:** 80
|
- **Concepts:** 80
|
||||||
- **Entities:** 72
|
- **Entities:** 72
|
||||||
- **Sources:** 27
|
- **Sources:** 28
|
||||||
- **Last Updated:** 2026-09-02
|
- **Last Updated:** 2026-09-03
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
|||||||
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
||||||
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
||||||
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
||||||
| `sources/` | 27 | [sources/INDEX.md](sources/INDEX.md) |
|
| `sources/` | 28 | [sources/INDEX.md](sources/INDEX.md) |
|
||||||
|
|
||||||
### entities/
|
### entities/
|
||||||
|
|
||||||
|
|||||||
@@ -121,3 +121,19 @@ Einheit u3 der Link-Taxonomie-Migration: alle 80 Seiten unter kb/concepts/ von P
|
|||||||
Abschluss der Korpus-Migration auf die Link-Taxonomie (Gitea #40, Abschnitte 2 und 3). Zuvor uebersehene Restmenge nachgeholt: 37 unlabelled edges in kb/entities/technologies und kb/entities/tools, die u1 nach dem damaligen 'protect, don't remove'-Muster bewusst unbelegt gelassen hatte - mit der u3-Erkenntnis, dass xref add nur die Quellseite anfasst, waren sie gefahrlos nachlabelbar (Wine-/Arch-/Agent-CLI-Cliquen ueberwiegend see-also, dazu echte Kanten: AUR part-of Arch Linux, Arch Linux uses GPG, Obsidian hosts Dataview/Marp, Obsidian required-by Obsidian Web Clipper, Wine required-by Proton, gdeploy uses Go). Erst damit erfuellt der Korpus das Abschlusskriterium des Migrationsdokuments (Schritt 6: unlabelled_edges und unauthorised_labels muessen 0 sein) - vorher waere migrate done eine unbelegte Behauptung gewesen. Endstand: lint meldet 0 unlabelled_edges, 0 unauthorised_labels, 0 malformed_edges, 0 unbalanced_markers, 0 broken_links, 0 dangling_frontmatter_refs, 0 schema_validation_errors; lint --fail-on-error und docs verify beide exit 0. migrate done 4.0.0 --pages 153 gesetzt, kb_version steht auf 4.0.0. VERSION stand bereits auf 4.0.0 (Bump erfolgte mit dem Mechanismus in u0, Commit 177c7e9), ein zweiter Bump entfaellt daher, und CHANGES.md dokumentiert 4.0.0 bereits vollstaendig. Workshop work/link-taxonomy-migration/ nach work/CONTRACT.md geschlossen und geloescht; die dauerhafte Ausgabe ist der gelabelte Korpus selbst. Eine Notiz aus glossary.md hat sich beim Abschluss als Rueckschritt erwiesen: die dort als Erkenntnis notierte Asymmetrie zwischen xref add (einseitig) und xref remove (bidirektional) steht seit jeher woertlich in tools/CONTRACT.md Zeilen 43-44 - sie war nachzulesen, nicht zu entdecken. Offen und an #40 gemeldet: im Katalog fehlt ein Label fuer Urheberschaft (Person erstellt Entity oder Concept); alle solchen Kanten stehen jetzt auf see-also.
|
Abschluss der Korpus-Migration auf die Link-Taxonomie (Gitea #40, Abschnitte 2 und 3). Zuvor uebersehene Restmenge nachgeholt: 37 unlabelled edges in kb/entities/technologies und kb/entities/tools, die u1 nach dem damaligen 'protect, don't remove'-Muster bewusst unbelegt gelassen hatte - mit der u3-Erkenntnis, dass xref add nur die Quellseite anfasst, waren sie gefahrlos nachlabelbar (Wine-/Arch-/Agent-CLI-Cliquen ueberwiegend see-also, dazu echte Kanten: AUR part-of Arch Linux, Arch Linux uses GPG, Obsidian hosts Dataview/Marp, Obsidian required-by Obsidian Web Clipper, Wine required-by Proton, gdeploy uses Go). Erst damit erfuellt der Korpus das Abschlusskriterium des Migrationsdokuments (Schritt 6: unlabelled_edges und unauthorised_labels muessen 0 sein) - vorher waere migrate done eine unbelegte Behauptung gewesen. Endstand: lint meldet 0 unlabelled_edges, 0 unauthorised_labels, 0 malformed_edges, 0 unbalanced_markers, 0 broken_links, 0 dangling_frontmatter_refs, 0 schema_validation_errors; lint --fail-on-error und docs verify beide exit 0. migrate done 4.0.0 --pages 153 gesetzt, kb_version steht auf 4.0.0. VERSION stand bereits auf 4.0.0 (Bump erfolgte mit dem Mechanismus in u0, Commit 177c7e9), ein zweiter Bump entfaellt daher, und CHANGES.md dokumentiert 4.0.0 bereits vollstaendig. Workshop work/link-taxonomy-migration/ nach work/CONTRACT.md geschlossen und geloescht; die dauerhafte Ausgabe ist der gelabelte Korpus selbst. Eine Notiz aus glossary.md hat sich beim Abschluss als Rueckschritt erwiesen: die dort als Erkenntnis notierte Asymmetrie zwischen xref add (einseitig) und xref remove (bidirektional) steht seit jeher woertlich in tools/CONTRACT.md Zeilen 43-44 - sie war nachzulesen, nicht zu entdecken. Offen und an #40 gemeldet: im Katalog fehlt ein Label fuer Urheberschaft (Person erstellt Entity oder Concept); alle solchen Kanten stehen jetzt auf see-also.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2026-09-02] update | Issue Label Scheme - Vierachsen-Schema und Body-als-Wahrheit
|
||||||
|
|
||||||
|
Gitea-Issue #41 als raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md aufgenommen und als Source-Seite erfasst. kb/concepts/Issue Label Scheme.md auf den Stand vom 2026-09-02 gebracht: vier Pflicht-Achsen (area/kind/prio/size), zwei optionale status/-Flags, Body-als-Wahrheit-Konvention. Das abgeloeste Zweiachsen-Schema steht als Abschnitt Historie mit Diff-Tabelle in der Seite, nicht geloescht. confidence_base 0.70 -> 0.85 (zweite unabhaengige Quelle, Bestaetigung juenger als 30 Tage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-09-03] update | Link-Taxonomie 4.1.0: 16 see-also-Kanten auf part-of, Comparison-Seite auf gelabelte Kanten
|
||||||
|
|
||||||
|
Befund 3 aus #40: composition/part-of ist jetzt das dritte Inversenpaar. Die 16 Gegenkanten eines composition, die im u3-Lauf auf see-also gesetzt wurden, sind per xref add auf part-of relabelt - betroffen sind Consolidation Tiers, Content Quality Control, Hybrid Search, Knowledge Graph, LLM Wiki Pattern, Memory Lifecycle, Multi-Agent Collaboration, Wine GE und farzaa gist samt ihrer Kinder.
|
||||||
|
|
||||||
|
Befund 1 aus #40: die einzige Comparison-Seite (amd-pstate vs acpi-cpufreq) trug ihre compares-with-Bullets als handgeschriebene Prosa ohne Frontmatter-Deckung, weil types/comparison.md kein related: fuehrte. Der Type-Spec hat es jetzt; die beiden Kanten stehen als deklarierte Kanten in einer wikitool:links-Region. kb/sources/COLLECTION.md hat seinen inerten outbound:-Block verloren.
|
||||||
|
|
||||||
|
migrate verify --from HEAD: 181 Seiten, 0 hinzugefuegt, 0 entfernt, 18 Befunde - alle Label-Wechsel im Frontmatter, keine Aenderung an Wikilink- oder Zitatzahlen. lint --fail-on-error gruen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+12
-2
@@ -8,8 +8,8 @@ inline `[^cite-id]` footnote).
|
|||||||
|
|
||||||
## Coverage Summary
|
## Coverage Summary
|
||||||
|
|
||||||
- **Total raw files:** 26
|
- **Total raw files:** 28
|
||||||
- **Covered:** 26
|
- **Covered:** 28
|
||||||
- **Uncovered:** 0
|
- **Uncovered:** 0
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -106,6 +106,11 @@ inline `[^cite-id]` footnote).
|
|||||||
- Covered by: [[Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31]]
|
- Covered by: [[Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31]]
|
||||||
- Cited by: [[Chemenu]], [[Command Round-Trip Integrity]], [[Denylist over Allowlist]], [[Detect-Repair Asymmetry]], [[Gitea]], [[Green Suite Blind Spot]], [[Write-Once Frontmatter Fields]], [[wikitool]]
|
- Cited by: [[Chemenu]], [[Command Round-Trip Integrity]], [[Denylist over Allowlist]], [[Detect-Repair Asymmetry]], [[Gitea]], [[Green Suite Blind Spot]], [[Write-Once Frontmatter Fields]], [[wikitool]]
|
||||||
|
|
||||||
|
### `raw/notes/Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md`
|
||||||
|
|
||||||
|
- Covered by: [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||||
|
- Cited by: [[Chemenu]], [[KB Stack Versioning]], [[wikitool]]
|
||||||
|
|
||||||
### `raw/notes/Conversation Transcript - Versioning, CI-CD and Content Migration Session 2026-08-30.md`
|
### `raw/notes/Conversation Transcript - Versioning, CI-CD and Content Migration Session 2026-08-30.md`
|
||||||
|
|
||||||
- Covered by: [[Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30]]
|
- Covered by: [[Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30]]
|
||||||
@@ -121,6 +126,11 @@ inline `[^cite-id]` footnote).
|
|||||||
- Covered by: [[Source - Docker Cheatsheet]]
|
- Covered by: [[Source - Docker Cheatsheet]]
|
||||||
- Cited by: [[Docker]]
|
- Cited by: [[Docker]]
|
||||||
|
|
||||||
|
### `raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md`
|
||||||
|
|
||||||
|
- Covered by: [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]]
|
||||||
|
- Cited by: [[Chemenu]], [[Gitea MCP Server]], [[Issue Label Scheme]]
|
||||||
|
|
||||||
### `raw/notes/Wine.md`
|
### `raw/notes/Wine.md`
|
||||||
|
|
||||||
- Covered by: [[Source - Wine]]
|
- Covered by: [[Source - Wine]]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
profile: sources
|
profile: sources
|
||||||
outbound:
|
|
||||||
any: [is-evidence-for, defined-in, see-also]
|
|
||||||
required_by_stack: true
|
required_by_stack: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,16 +38,17 @@ The `raw_files:`/`source_url:`/citation rules are shared and live in
|
|||||||
- `tools/wikitool sources trace --raw <path>` answers "what did we learn from this?";
|
- `tools/wikitool sources trace --raw <path>` answers "what did we learn from this?";
|
||||||
`tools/wikitool sources coverage` lists raw files no source page claims yet.
|
`tools/wikitool sources coverage` lists raw files no source page claims yet.
|
||||||
|
|
||||||
## Authorised labels
|
## No authorised labels
|
||||||
|
|
||||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
This collection has **no `outbound:` block**, and that is the declaration rather than an
|
||||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
omission: the `source` type-spec offers no `related:` field, so a source page has nowhere to
|
||||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
put a labelled edge. Everything it would want to assert is already carried by `raw_files:`,
|
||||||
nothing on its own.
|
`entities:`, `concepts:` and `[^cite-id]` - the mechanical provenance path, not authored edges.
|
||||||
|
|
||||||
Deliberately narrow. A source page is evidence *about* a source; almost everything it would want to say is already carried by `raw_files:`, `sources:` and `[^cite-id]`, which are the mechanical provenance path rather than authored edges.
|
An `outbound:` block here would authorise labels that no page in this collection can write.
|
||||||
|
`wikitool docs verify` refuses that combination, so the two cannot drift apart: giving source
|
||||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
pages labelled edges means giving the type-spec a `related:` field first, which is a deliberate
|
||||||
|
contract change and not a way around a refusal.
|
||||||
|
|
||||||
## Outbound linking
|
## Outbound linking
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# kb/sources/ - Index
|
# kb/sources/ - Index
|
||||||
|
|
||||||
27 page(s). Regenerated by `wikitool index rebuild`.
|
28 page(s). Regenerated by `wikitool index rebuild`.
|
||||||
|
|
||||||
## All
|
## All
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
| [[Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31]] | notes | Sitzung, die write-once-Frontmatterfelder reparierbar macht: touch bekommt --set/--add/--remove ueber eine Denylist statt einer Allowlist, ein idempotentes --remove und einen bewusst engen Scope (Stack 1.4.0, Gitea-Issue #14) | 2026-08-31 |
|
| [[Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31]] | notes | Sitzung, die write-once-Frontmatterfelder reparierbar macht: touch bekommt --set/--add/--remove ueber eine Denylist statt einer Allowlist, ein idempotentes --remove und einen bewusst engen Scope (Stack 1.4.0, Gitea-Issue #14) | 2026-08-31 |
|
||||||
| [[Source - Copilot Skill Restructure Instructions]] | notes | Anweisungssatz zur Aufteilung der monolithischen AGENTS.md in einzelne plattformübergreifende Agent-Skills | 2026-08-03 |
|
| [[Source - Copilot Skill Restructure Instructions]] | notes | Anweisungssatz zur Aufteilung der monolithischen AGENTS.md in einzelne plattformübergreifende Agent-Skills | 2026-08-03 |
|
||||||
| [[Source - Docker Cheatsheet]] | notes | Praktisches Bash-Skript zur Fehlersuche bei Docker-Volumes und Overlay2, um den Container zu einem Verzeichnis im Dateisystem zu ermitteln. | 2026-07-31 |
|
| [[Source - Docker Cheatsheet]] | notes | Praktisches Bash-Skript zur Fehlersuche bei Docker-Volumes und Overlay2, um den Container zu einem Verzeichnis im Dateisystem zu ermitteln. | 2026-07-31 |
|
||||||
|
| [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]] | notes | Threadkopie zu Gitea-Issue #41: vier Pflicht-Label-Achsen statt zwei, zwei optionale status/-Flags, und der Issue-Body als aktuelle Wahrheit statt als Ursprungstext | 2026-09-02 |
|
||||||
| [[Source - LLM Improvements Codex Analysis]] | notes | Codex-Analyse, die AGENTS.md und wikitool mit awesome-llm-wiki und Farzas Gist vergleicht und 7 aussichtsreiche Verbesserungen sowie zu vermeidende Anti-Muster benennt. Hinweis: eine Sonnet-Analyse zum Vergleich ist vorgesehen. | 2026-08-03 |
|
| [[Source - LLM Improvements Codex Analysis]] | notes | Codex-Analyse, die AGENTS.md und wikitool mit awesome-llm-wiki und Farzas Gist vergleicht und 7 aussichtsreiche Verbesserungen sowie zu vermeidende Anti-Muster benennt. Hinweis: eine Sonnet-Analyse zum Vergleich ist vorgesehen. | 2026-08-03 |
|
||||||
| [[Source - LLM Improvements Production Agent Gaps 2026]] | notes | Externe Kritik (dzone, 2026) am Fehlen harter Iterations- und Kostengrenzen sowie eines Loop-Breakers; umgesetzt als Iteration Budget Gate in wikitool. | 2026-08-07 |
|
| [[Source - LLM Improvements Production Agent Gaps 2026]] | notes | Externe Kritik (dzone, 2026) am Fehlen harter Iterations- und Kostengrenzen sowie eines Loop-Breakers; umgesetzt als Iteration Budget Gate in wikitool. | 2026-08-07 |
|
||||||
| [[Source - LLM Improvements Sonnet Analysis]] | notes | Sonnet-Analyse, die AGENTS.md und wikitool mit Farzas Gist und awesome-llm-wiki vergleicht und die Codex-Analyse um konkrete Empfehlungen zu Qualitätsschwellen, Stilrichtlinie, Auditrhythmus und Skalierung ergänzt | 2026-08-03 |
|
| [[Source - LLM Improvements Sonnet Analysis]] | notes | Sonnet-Analyse, die AGENTS.md und wikitool mit Farzas Gist und awesome-llm-wiki vergleicht und die Codex-Analyse um konkrete Empfehlungen zu Qualitätsschwellen, Stilrichtlinie, Auditrhythmus und Skalierung ergänzt | 2026-08-03 |
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
type: types/source.md
|
||||||
|
source_type: notes
|
||||||
|
author: Torben Nehmer
|
||||||
|
raw_files: [raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md]
|
||||||
|
source_language: de
|
||||||
|
date: 2026-09-02
|
||||||
|
tags: [issues, gitea, labels, triage, process]
|
||||||
|
entities: [Chemenu, Gitea MCP Server]
|
||||||
|
concepts: [Issue Label Scheme]
|
||||||
|
summary: 'Threadkopie zu Gitea-Issue #41: vier Pflicht-Label-Achsen statt zwei, zwei optionale status/-Flags, und der Issue-Body als aktuelle Wahrheit statt als Ursprungstext'
|
||||||
|
---
|
||||||
|
# Source: Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02
|
||||||
|
|
||||||
|
**Autor:** Torben Nehmer
|
||||||
|
**Datum:** 2026-09-02
|
||||||
|
**Raw-Dateien:** raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md
|
||||||
|
**Typ:** Notes
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
Wörtliche Kopie des Threads zu Gitea-Issue #41, gezogen am 2026-09-02: Issue-Body im Stand
|
||||||
|
nach der Umsetzung, die drei Changelog-Kommentare und das zu diesem Zeitpunkt in Gitea
|
||||||
|
angelegte Label-Set. Das Issue hält eine Diskussion vom selben Tag fest, die aus der
|
||||||
|
Aufarbeitung von fünf als "Fallouts" des Entwicklungsprozesses eingestuften Issues (#38, #30,
|
||||||
|
#28, #7, #27) hervorging und den Rahmen für deren Bearbeitung setzen sollte.
|
||||||
|
|
||||||
|
Verhandelt wurden drei Dinge. Erstens bleibt ein eingehender Wunsch ein Issue und wird keine
|
||||||
|
`kb/`-Seite, weil Issues ephemer sind und Wünsche abbilden - neu ist daran nur, wie ein Issue
|
||||||
|
gepflegt wird: der Body ist aktuelle Wahrheit und wird umgeschrieben, Kommentare tragen einen
|
||||||
|
Changelog statt einer Vollkopie, und beides macht in der Regel eine LLM-Sitzung. Zweitens
|
||||||
|
werden aus zwei Pflicht-Label-Achsen vier: `area/`, `kind/`, `prio/` und `size/`. Drittens
|
||||||
|
kommen zwei optionale Flags dazu, `status/blocked` und `status/unconfirmed`.
|
||||||
|
|
||||||
|
Die Quelle ist zugleich der Beleg für die Umsetzung: das Issue verzeichnet, dass das
|
||||||
|
Label-Set in Gitea steht und dass das Schema seit Stack-Version `4.0.1` kanonisch in
|
||||||
|
`instructions/dev/issue-tracking.md` liegt.
|
||||||
|
|
||||||
|
## Kernaussagen
|
||||||
|
|
||||||
|
- **Der Issue-Body ist die Lifeline für das Agent-Memory.** Die Umsetzung eines Issues zieht
|
||||||
|
sich über mehrere, zeitlich getrennte LLM-Sitzungen, und der Body ist der einzige Ort, der
|
||||||
|
sie verbindet: eine Sitzung muss allein aus ihm rekonstruieren können, was entschieden und
|
||||||
|
was offen ist. Ein additiv wachsendes Log zwingt dagegen zum Lesen der ganzen Geschichte,
|
||||||
|
um den aktuellen Stand herauszufiltern.
|
||||||
|
- **Kommentare sind Changelog, nicht Kopie.** Ein Volltext-Snapshot des alten Bodys pro
|
||||||
|
Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare
|
||||||
|
Historie, sondern nur eine weitere Kopie. Der Kommentar nennt nur, was neu, entfallen oder
|
||||||
|
korrigiert ist.
|
||||||
|
- **Vier Pflichtachsen sind bezahlbar geworden, weil die Pflege maschinell läuft.** Der
|
||||||
|
ursprüngliche Einwand gegen eine dritte Achse war der Pflegeaufwand für einen einzelnen
|
||||||
|
menschlichen Betreuer; da Body-Rewrites und Labelpflege über eine LLM-Sitzung laufen, trägt
|
||||||
|
er nicht mehr.
|
||||||
|
- **`area/` folgt der Systemgrenze, nicht dem Codeort.** Die Werte `kb`, `distribution`,
|
||||||
|
`corpus`, `workflow`, `process` folgen der Stufenteilung aus `AGENTS.md`. Ein `area/tools`
|
||||||
|
gibt es bewusst nicht - Tooling wird nach der Domäne einsortiert, die es bedient.
|
||||||
|
- **`kind/` darf sich im Lauf eines Issues ändern.** Der Wechsel von `decision` zu `build`,
|
||||||
|
sobald entschieden ist, ist erwünschtes Session-Memory-Verhalten und kein Makel.
|
||||||
|
- **`prio/` wurde nur umbenannt.** `1`/`2`/`3` heißen jetzt `blocking`/`planned`/`waiting`,
|
||||||
|
die Bedeutung ist unverändert. Bei `size/` entfällt `XS`; `S`/`M`/`L` bleiben, wie sie
|
||||||
|
waren.
|
||||||
|
- **Ein unbelegter Verdacht bleibt nicht offen liegen.** Solange `status/unconfirmed` gesetzt
|
||||||
|
ist, sind `size` und `prio` vorläufig. Die Triage endet mit entferntem Flag und
|
||||||
|
verbindlichen Werten oder mit einem geschlossenen Issue samt Begründung - vom Issue selbst
|
||||||
|
als Prozessentsprechung zu Invariante 3 des Stacks bezeichnet.
|
||||||
|
- **Sechzehn Label stehen in Gitea**, gelesen am 2026-09-02: fünf `area/`, drei `kind/`, drei
|
||||||
|
`prio/`, drei `size/`, zwei `status/`. `size/XS`, `prio/1`, `prio/2` und `prio/3`
|
||||||
|
existieren nicht mehr.
|
||||||
|
- **Der Release war ein PATCH.** `4.0.1`, weil `dist export` `instructions/dev/` vollständig
|
||||||
|
ausschließt und sich für eine ausgelieferte Instanz nichts ändert - dieselbe Begründung wie
|
||||||
|
bei `1.2.1`, das das Zweiachsen-Schema eingeführt hatte.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Bestehende Sachissues nach und nach auf die vier Pflicht-Label umstellen (laut Issue
|
||||||
|
bereits umgestellt: #38, #27 geschlossen, #7, #42)
|
||||||
|
|
||||||
|
## Nicht übernommen
|
||||||
|
|
||||||
|
- **Die Diskussion, aus der das Issue hervorging.** Das Issue nennt sie ("Diskussion vom
|
||||||
|
2026-09-02"), aber ihr Transkript liegt nicht in `raw/`. Die Quelle ist damit das Ergebnis
|
||||||
|
der Debatte, nicht ihr Verlauf; die verworfenen Alternativen sind nicht rekonstruierbar.
|
||||||
|
- **Die Sachinhalte der referenzierten Issues #38, #30, #28, #27, #7, #42, #39, #40.** Sie
|
||||||
|
kommen im Thread nur als Nummern vor. Was in ihnen steht, gehört in die Seiten zu den
|
||||||
|
jeweiligen Gegenständen, nicht hierher.
|
||||||
|
- **Der Volltext von `instructions/dev/issue-tracking.md`.** Das Issue beschreibt, was dort
|
||||||
|
hineingeschrieben wurde; die Instruction selbst ist Teil des Stacks und kein Rohmaterial.
|
||||||
|
|
||||||
|
## Verwandte Entities
|
||||||
|
|
||||||
|
- [[Chemenu]]
|
||||||
|
- [[Gitea MCP Server]]
|
||||||
|
|
||||||
|
## Verwandte Concepts
|
||||||
|
|
||||||
|
- [[Issue Label Scheme]]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Gitea Issue #41 — Issue-Management: Label-Schema und Body-als-Wahrheit-Konvention
|
||||||
|
|
||||||
|
Wörtliche Kopie des Issue-Threads von <https://gitea.nehmer.net/torben/chemenu/issues/41>,
|
||||||
|
gezogen am 2026-09-02 nach dem Body-Rewrite, der die Umsetzung in `4.0.1` festhält. Autor
|
||||||
|
aller Beiträge: torben. Erstellt 2026-09-02T20:48:56Z, zuletzt geändert 2026-09-02T21:13:11Z.
|
||||||
|
|
||||||
|
Labels zum Zeitpunkt der Kopie: `area/process`, `kind/build`, `prio/blocking`, `size/M`.
|
||||||
|
Status: offen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue-Body (Stand 2026-09-02T21:13:11Z)
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Bündelt die Ergebnisse einer Diskussion am 2026-09-02 über den Entwicklungsprozess dieses Repos, ausgehend von den Fallouts in #38, #30, #28, #7, #27. Bewusst das erste Issue, das umgesetzt wird - es setzt den Rahmen für die Bearbeitung aller anderen.
|
||||||
|
|
||||||
|
**Stand:** Label-Set steht in Gitea, das Schema ist seit `4.0.1` kanonisch in `instructions/dev/issue-tracking.md` (Commit `c8c2385`). Offen ist nur noch die Relabelung der verbliebenen Sachissues und eine `kb/`-Seite, die noch das alte Schema beschreibt.
|
||||||
|
|
||||||
|
## Entscheidung 1: Issues bleiben Storage für eingehende Specs, mit schärferer Pflege
|
||||||
|
|
||||||
|
Ein eingehender Wunsch/Requirement bleibt Issue, nicht `kb/`-Seite - Issues sind ephemer und bilden Wünsche ab, `kb/` bildet verifiziertes, dauerhaftes Wissen ab (unverändert gegenüber `instructions/dev/issue-tracking.md`).
|
||||||
|
|
||||||
|
**Motivation für die verschärfte Pflege:** Die Umsetzung der hier verhandelten Issues zieht sich über mehrere, oft zeitlich getrennte LLM-Sitzungen. Der Issue-Body ist der einzige Ort, der diese Sitzungen verbindet - er ist die Lifeline für das Agent-Memory. Eine Sitzung, die ein Issue neu öffnet, muss allein aus dem Body rekonstruieren können, was entschieden ist und was noch offen ist, ohne dass ein Mensch den Kontext erneut vorkaut. Ein additiv wachsendes Log zwingt zum Lesen der ganzen Geschichte, um den aktuellen Stand herauszufiltern - ein aktuell gehaltener Body liefert ihn direkt. Das ist der eigentliche Grund für die folgenden drei Regeln, nicht Ordnung um der Ordnung willen.
|
||||||
|
|
||||||
|
- **Body = aktuelle Wahrheit.** Der Body wird aktiv umgeschrieben, wenn sich der Stand ändert - kein additives Anhängen an einen veralteten Ursprungstext.
|
||||||
|
- **Kommentare = Changelog, nicht Kopie.** Beim Body-Rewrite wird kein Volltext-Snapshot des alten Stands als Kommentar gesichert, sondern ein kurzer Changelog-Eintrag, der nur benennt, was sich gegenüber dem vorherigen Stand geändert hat - neu, entfallen, korrigiert. Eine Vollkopie pro Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare Historie, sondern nur eine weitere Kopie.
|
||||||
|
- **Bearbeitung primär durch LLM.** Menschen fassen in der Regel nur Labels/Metadaten direkt an; Body-Rewrites und Kommentare laufen über eine LLM-Sitzung.
|
||||||
|
|
||||||
|
## Entscheidung 2: Vier Pflicht-Label-Familien statt zwei
|
||||||
|
|
||||||
|
| Familie | Werte | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `area/` | `kb`, `distribution`, `corpus`, `workflow`, `process` | Welche Systemgrenze betroffen ist, entlang der bestehenden Stufenteilung aus `AGENTS.md` (kein `area/tools` - Tooling wird nach der Domäne eingeordnet, die es bedient, nicht nach Codeort) |
|
||||||
|
| `size/` | `S`, `M`, `L` (verdichtet von vier auf drei Stufen, `XS` entfällt) | Aufwand, unverändert in der Bedeutung von `S`/`M`/`L` |
|
||||||
|
| `prio/` | `blocking`, `planned`, `waiting` (Umbenennung von `1`/`2`/`3`, Bedeutung unverändert) | Dringlichkeit, weiterhin fließend zu handhaben |
|
||||||
|
| `kind/` | `decision`, `build`, `defect` | Art der Offenheit: wartet auf eine Betreiberentscheidung, ist spezifiziert und wartet auf Umsetzungszeit, oder ist ein Befund über einen Widerspruch. Darf sich im Lauf eines Issues ändern (z.B. `decision` → `build`, sobald entschieden) - das ist erwünschtes Session-Memory-Verhalten, kein Makel |
|
||||||
|
|
||||||
|
Alle vier sind Pflicht auf jedem offenen Issue, weil maschinelle Pflege durch das LLM den ursprünglichen Einwand gegen eine dritte/vierte Achse (Pflegeaufwand für einen einzelnen Menschen) entkräftet.
|
||||||
|
|
||||||
|
## Entscheidung 3: Zwei optionale Status-Flags
|
||||||
|
|
||||||
|
- `status/blocked` - wartet auf ein anderes, noch offenes Issue; unabhängig vom `prio`-Wert nicht eigenständig bearbeitbar. Nicht mandatory, weil es eine Beziehung zwischen Issues abbildet, keine Eigenschaft eines einzelnen.
|
||||||
|
- `status/unconfirmed` - gemeldeter Verdacht, noch nicht gegen tatsächliches Verhalten geprüft. Gilt für jeden `kind`-Wert, nicht nur `defect`. Solange gesetzt, sind `size` und `prio` vorläufig. Nach Triage: Flag entfernt und `size`/`prio` verbindlich gesetzt, oder Issue mit Begründung geschlossen (kein unbelegter Verdacht bleibt offen liegen - Analogie zu Invariante 3 des Stacks, nur auf Prozessebene).
|
||||||
|
|
||||||
|
## Migration der bestehenden Labels
|
||||||
|
|
||||||
|
`prio/1` → `prio/blocking`, `prio/2` → `prio/planned`, `prio/3` → `prio/waiting` (reine Umbenennung). `size/XS` entfällt, `size/S`/`M`/`L` bleiben unverändert. `area/*`, `kind/*`, `status/*` sind neu. **Erledigt** - alle 16 Label stehen in Gitea.
|
||||||
|
|
||||||
|
## Umgesetzt in `4.0.1`
|
||||||
|
|
||||||
|
`instructions/dev/issue-tracking.md` ist auf das Schema umgeschrieben: Schritt 2 (Body als aktuelle Wahrheit inkl. Mehrsitzungs-Begründung), Schritt 3 (Changelog-Kommentar statt Vollkopie, mit Beispiel), Schritt 4 (alle vier Pflichtachsen als vier Tabellen), Schritt 5 (die beiden `status/`-Flags und der Triage-Ausgang), Schritt 6 (Re-Labeling schließt `kind/`-Wechsel ein). Der Entscheidungspunkt „Two labels feel too coarse?" ist entfallen, an seine Stelle treten zwei neue („Rewrite the body, or add a comment?" und der Umgang mit Altissues, die nur zwei Label tragen). Die Beschreibungszeile in `instructions/dev/stack-dev/SKILL.md` nennt jetzt die vier Achsen und die Body-Konvention.
|
||||||
|
|
||||||
|
PATCH und nicht MINOR, weil `dist export` `instructions/dev/` vollständig ausschließt - für eine ausgelieferte Instanz ändert sich nichts. Gleiche Begründung wie bei `1.2.1`, das das ursprüngliche Zweiachsen-Schema eingeführt hat.
|
||||||
|
|
||||||
|
## Nicht in diesem Issue
|
||||||
|
|
||||||
|
Die Relabelung der bestehenden Sachissues erfolgt in deren jeweiligen Einzel-Sitzungen. Bereits umgestellt: #38, #27 (geschlossen), #7, #42.
|
||||||
|
|
||||||
|
`kb/concepts/Issue Label Scheme.md` beschreibt weiterhin das zweiachsige Schema von 2026-08-31 (`prio/1..3`, `size/XS..L`, „keine dritte Achse") und ist damit veraltet. Die Aktualisierung ist ein `kb/`-Schreibzugriff und braucht eine `wiki-manage`-Sitzung mit ordentlicher Quelle - nicht Teil dieses Issues.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [x] Label-Set in Gitea angelegt/umbenannt (dieses Issue)
|
||||||
|
- [x] `instructions/dev/issue-tracking.md` in einer stack-dev-Sitzung um dieses Schema ergänzt (`4.0.1`, Commit `c8c2385`)
|
||||||
|
- [ ] Bestehende Sachissues nach und nach auf die vier Pflicht-Label umgestellt
|
||||||
|
- [ ] `kb/concepts/Issue Label Scheme.md` in einer `wiki-manage`-Sitzung nachgezogen
|
||||||
|
- [ ] Dieses Issue dient bis dahin als Referenz für das Schema
|
||||||
|
|
||||||
|
## Vorgeschichte
|
||||||
|
|
||||||
|
Entschieden in der Diskussion vom 2026-09-02, im Rahmen einer Aufarbeitung von #38, #30, #28, #7, #27 als "Fallouts" des Entwicklungsprozesses. #39 und #40 liefen parallel und unabhängig, nicht Teil dieser Debatte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kommentar 1 (2026-09-02T20:57:51Z, issuecomment-512)
|
||||||
|
|
||||||
|
**Changelog:** Entscheidung 1, dritter Punkt korrigiert. Vorher: "Kommentare = Historie", der bisherige Body-Stand wird beim Umschreiben vollständig als Kommentar gesichert. Jetzt: "Kommentare = Changelog, nicht Kopie" - ein Kommentar nennt nur, was sich geändert hat, keine Volltextkopie des alten Bodys. Grund: eine Vollkopie pro Revision ist für einen Menschen nicht diffbar und damit keine brauchbare Historie.
|
||||||
|
|
||||||
|
## Kommentar 2 (2026-09-02T21:07:47Z, issuecomment-539)
|
||||||
|
|
||||||
|
**Changelog:** Entscheidung 1 um Motivationsabsatz ergänzt (Body als Lifeline für Agent-Memory über mehrere Sitzungen hinweg, nicht Ordnung um der Ordnung willen). Akzeptanzkriterium 1 abgehakt, Referenzliste der bereits umgestellten Issues (#38, #27, #7, #42) ergänzt.
|
||||||
|
|
||||||
|
## Kommentar 3 (2026-09-02T21:13:19Z, issuecomment-545)
|
||||||
|
|
||||||
|
**Changelog:** Akzeptanzkriterium 2 abgehakt - das Schema ist mit `4.0.1` (Commit `c8c2385`) kanonisch in `instructions/dev/issue-tracking.md`. Neu: Stand-Zeile im Kontext, Abschnitt „Umgesetzt in `4.0.1`" (was genau in der Instruction steht, und warum PATCH), Vermerk „Erledigt" an der Label-Migration. Neu als offener Punkt und als fünftes Akzeptanzkriterium: `kb/concepts/Issue Label Scheme.md` beschreibt noch das Zweiachsen-Schema und braucht eine eigene `wiki-manage`-Sitzung. Entfallen: der Absatz „Die eigentliche Textänderung ... braucht eine separate stack-dev-Sitzung" - genau die ist jetzt gelaufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Label-Set in Gitea (gelesen 2026-09-02, `label_read list_repo_labels`)
|
||||||
|
|
||||||
|
| Label | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `area/kb` | Betrifft kb/-Schema, Contract, Confidence, Lint, Wissensbasis |
|
||||||
|
| `area/distribution` | Betrifft Auslieferung, Upgrade, Versionierung einer Instanz |
|
||||||
|
| `area/corpus` | Betrifft Demo-/Testbett-Frage, Inhalt und Umfang von kb/ |
|
||||||
|
| `area/workflow` | Betrifft Git, Merge, Branching, Publish, PRs |
|
||||||
|
| `area/process` | Betrifft den Entwicklungsprozess selbst, nicht den Stack als Artefakt |
|
||||||
|
| `kind/decision` | Wartet auf eine Betreiberentscheidung |
|
||||||
|
| `kind/build` | Spezifiziert, wartet nur noch auf Umsetzungszeit |
|
||||||
|
| `kind/defect` | Befund: Doku und Realitaet, oder zwei Dokus, widersprechen sich |
|
||||||
|
| `prio/blocking` | Blockiert oder beschaedigt laufende Arbeit - als naechstes |
|
||||||
|
| `prio/planned` | Traegt bald Zinsen - eingeplant |
|
||||||
|
| `prio/waiting` | Sinnvoll, wartet auf einen Ausloeser |
|
||||||
|
| `size/S` | Eine Sitzung, ein Publish, klar umrissener Schnitt |
|
||||||
|
| `size/M` | Mehrere Dateien, Contract- oder Instruction-Aenderung, eigener Testaufwand |
|
||||||
|
| `size/L` | Mehrere Sitzungen oder offene Designfragen vor dem ersten Commit |
|
||||||
|
| `status/blocked` | Wartet auf ein anderes, noch offenes Issue - nicht eigenstaendig bearbeitbar |
|
||||||
|
| `status/unconfirmed` | Gemeldeter Verdacht, noch nicht gegen tatsaechliches Verhalten geprueft - size/prio vorlaeufig |
|
||||||
|
|
||||||
|
Sechzehn Label. `size/XS`, `prio/1`, `prio/2` und `prio/3` existieren nicht mehr.
|
||||||
+22
-4
@@ -11,10 +11,27 @@
|
|||||||
# measure the stack's tests - so an addopts entry would break the plain
|
# measure the stack's tests - so an addopts entry would break the plain
|
||||||
# `pytest -q` that every local run and the CI "Tests" step use.
|
# `pytest -q` that every local run and the CI "Tests" step use.
|
||||||
#
|
#
|
||||||
# No `fail_under` yet, on purpose: Gitea #10 sets the threshold in a separate,
|
# `fail_under` lives here rather than as a `--cov-fail-under` flag in the CI
|
||||||
# later commit, once the measured number exists to justify it. A threshold
|
# step, so the number sits next to the reasoning that produced it and applies to
|
||||||
# picked before the number is either too low to bite or too high to survive the
|
# any `--cov` run, not just the one CI happens to write.
|
||||||
# next honest commit - and the second kind gets lowered rather than earned.
|
#
|
||||||
|
# 85, against a measured 87.0% (CI run 163, 6498 statements, 975 tests). Gitea
|
||||||
|
# #10 held this back until the number had been watched: the first measurement
|
||||||
|
# was 86.9% of 5105 statements over 730 tests (CI run 87), and between the two
|
||||||
|
# the measured code grew by a quarter and the suite by a third while the quota
|
||||||
|
# moved a tenth of a point. That stability is what the threshold rests on.
|
||||||
|
#
|
||||||
|
# The two points of headroom are not slack. They are the room the report's own
|
||||||
|
# taxonomy asks for: a new thin Typer wrapper lowers the total without anything
|
||||||
|
# having got worse, because its logic is tested beside it (see EVALS.md § "How
|
||||||
|
# much of the stack the suite reaches"). A threshold at the measured number
|
||||||
|
# would go red on exactly that commit, and a threshold that goes red for a
|
||||||
|
# non-reason gets lowered rather than earned - which is the failure mode #10
|
||||||
|
# existed to avoid, arriving from the other side.
|
||||||
|
#
|
||||||
|
# What this number does *not* do is close the genuine gaps - provenance_cmd.py,
|
||||||
|
# migrate_cmd.py, type_resolver.py. It freezes the state that was reached; the
|
||||||
|
# gaps are their own work, tracked separately.
|
||||||
[run]
|
[run]
|
||||||
source = chemenu
|
source = chemenu
|
||||||
omit =
|
omit =
|
||||||
@@ -23,3 +40,4 @@ omit =
|
|||||||
[report]
|
[report]
|
||||||
show_missing = True
|
show_missing = True
|
||||||
precision = 1
|
precision = 1
|
||||||
|
fail_under = 85
|
||||||
|
|||||||
+14
-6
@@ -50,7 +50,7 @@ tools/wikitool <command> --help
|
|||||||
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
|
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
|
||||||
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
|
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
|
||||||
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
|
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
|
||||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||||
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
|
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
|
||||||
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
|
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
|
||||||
@@ -58,7 +58,7 @@ tools/wikitool <command> --help
|
|||||||
| `sources trace --raw <path>` \| `--page "<Title>"` | Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files |
|
| `sources trace --raw <path>` \| `--page "<Title>"` | 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) |
|
| `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` |
|
| `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 |
|
| `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 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/` |
|
| `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) |
|
| `budget status` | Show the current session's `wikitool` call count and recent command history (never counted against the budget) |
|
||||||
@@ -68,19 +68,23 @@ 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 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, 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 verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, 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 |
|
| `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 here (and vice versa), 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, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) |
|
| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), 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, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories). 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 |
|
||||||
| `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate |
|
| `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate |
|
||||||
| `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) |
|
| `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) |
|
||||||
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
||||||
|
| `dist upgrade <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/*/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren" |
|
||||||
| `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 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 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 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 "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) 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. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. 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 "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--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) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, 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 and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. 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 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. 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` |
|
||||||
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
||||||
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
||||||
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
|
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
|
||||||
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable. An `offered` migration is recorded in the applied ledger *without* moving `kb_version` and with no ordering rule applied: it is not a link in the chain, so there is nothing to skip, and requiring the chain first would make an unrelated file upgrade wait on it. Re-recording one already in the ledger is a no-op, not an error |
|
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable. An `offered` migration is recorded in the applied ledger *without* moving `kb_version` and with no ordering rule applied: it is not a link in the chain, so there is nothing to skip, and requiring the chain first would make an unrelated file upgrade wait on it. Re-recording one already in the ledger is a no-op, not an error |
|
||||||
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it |
|
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it |
|
||||||
|
| `upstream merge [--remote upstream] [--branch main] [--no-fetch]` | Take a stack update into a private instance's branch, machinery only - the code procedure behind `instructions/private-instance.md` § "Taking a stack update". Refuses on a dirty working tree, a merge already in progress, or a remote that does not resolve; WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing at the setup step that arms it. Fetches `<remote>/<branch>` (unless `--no-fetch`) and reports "already up to date" if nothing new exists. Otherwise opens `git merge --no-commit --no-ff <remote>/<branch>` - and stops, untouched, if git refused to open a merge at all (unrelated histories), since without a `MERGE_HEAD` every stack path would read as "the upstream deleted it". Then forces every content stage (`kb/`, `raw/`, `work/`, `reports/`) back to the local side by removing **only the paths tracked in either tree** and checking `HEAD`'s back out - never the stage directory wholesale, because `reports/` is gitignored apart from its contract and holds local, non-recomputable data (telemetry traces `eval score` reads, saved eval and lint reports) that no merge has business deleting. Then restores from the upstream side exactly the paths `chemenu.ownership.is_stack_owned` recognises as machinery (`<stage>/CONTRACT.md`, and anything ending `.template` under a content stage) - including a deletion, if the upstream removed one. A real conflict left in `tools/`, `types/` or `instructions/` after that leaves the merge open, uncommitted, and exits 1 rather than guessing. Commits with `git commit --no-edit`, then re-checks the resulting range with the same logic as `upstream verify`; a finding there is a loud, uncommitted-nothing-rolled-back error, because the merge commit already exists and needs a human's eyes, not an automatic repair. Never pushes. Not idempotent - see the tool error contract below |
|
||||||
|
| `upstream verify --since <rev> [--until HEAD]` | Compare two revisions: did anything under a content stage (`kb/`, `raw/`, `work/`, `reports/`) change except through a stack-owned path? Shares its check with `upstream merge`'s own postcheck, so a hand-resolved merge conflict, or a future `dist upgrade` (#7), can be verified the same way. Exit 1 with the offending paths if anything leaked; otherwise reports which stack-owned paths legitimately moved. Read-only and exempt from the Iteration Budget Gate, like `migrate verify` |
|
||||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
@@ -189,14 +193,18 @@ is atomic, and whether a retry is safe.
|
|||||||
| `instructions sync` / `verify` / `list` | Nothing found under `instructions/`, a malformed instruction or `SKILL.md`, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md or a skill and so risks running implicitly), something under `instructions/dev/` referenced from outside it and outside a dist:strip block, or (sync) a target directory that is not a published skill and `--force` was not passed | `sync` rewrites one directory per target (idempotent); `verify`/`list` are read-only | Fix the flagged file, then re-run. For drift, re-run `sync`: the source under `instructions/` always wins, and a published copy is never edited directly |
|
| `instructions sync` / `verify` / `list` | Nothing found under `instructions/`, a malformed instruction or `SKILL.md`, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md or a skill and so risks running implicitly), something under `instructions/dev/` referenced from outside it and outside a dist:strip block, or (sync) a target directory that is not a published skill and `--force` was not passed | `sync` rewrites one directory per target (idempotent); `verify`/`list` are read-only | Fix the flagged file, then re-run. For drift, re-run `sync`: the source under `instructions/` always wins, and a published copy is never edited directly |
|
||||||
| `docs verify` | A command, contract, or type-form mismatch was found | Read-only | Fix the documentation it names, then re-run |
|
| `docs verify` | A command, contract, or type-form mismatch was found | Read-only | Fix the documentation it names, then re-run |
|
||||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||||
|
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
||||||
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
||||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||||
|
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), or `VERSION` and the changelog's newest entry naming different versions | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||||
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
||||||
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
||||||
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
||||||
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain | Yes - single file write | **Not idempotent** for a required migration: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order. Recording an `offered` migration *is* idempotent and safe to repeat |
|
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain | Yes - single file write | **Not idempotent** for a required migration: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order. Recording an `offered` migration *is* idempotent and safe to repeat |
|
||||||
| `migrate baseline` | Unparseable version, or a declaration already exists and `--force` was not passed | Yes - single file write | Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted |
|
| `migrate baseline` | Unparseable version, or a declaration already exists and `--force` was not passed | Yes - single file write | Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted |
|
||||||
|
| `upstream merge` | Dirty working tree, a merge already in progress, the remote does not resolve, git refused to open the merge at all (unrelated histories), or a real conflict remains in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths were restored | **No** - can leave an open, uncommitted merge behind on refusal after fetching | **Not idempotent, and not safe to retry unchanged.** For a dirty tree or an in-progress merge: fix the named precondition and retry once. For a real conflict: **do not retry, do not force** - resolve the named paths by hand (take the upstream side, or re-file the local change as an issue against the public repo per `instructions/private-instance.md`) and either `git commit --no-edit` yourself or `git merge --abort`. If the postcheck after commit finds a leak, the merge commit already exists and is **not** rolled back automatically - inspect it by hand; this is a bug report, not a retry |
|
||||||
|
| `upstream verify` | A leak was found (content changed under a content stage through a path that is not stack-owned), or `--since`/`--until` is not a revision in this repository | Read-only | A finding is not fixed by re-running - it names the paths that leaked. Fix the revision argument and retry for the second case |
|
||||||
| `doctor` | At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) | Read-only | Each finding names its own fix command; re-run after applying it |
|
| `doctor` | At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) | Read-only | Each finding names its own fix command; re-run after applying it |
|
||||||
| `budget status` / `budget reset` | `reset` without `--yes`; `status` never fails | Read/rewrite of one JSON file | `status` is safe to retry. For `reset`: get the user's approval, then re-run with `--yes` |
|
| `budget status` / `budget reset` | `reset` without `--yes`; `status` never fails | Read/rewrite of one JSON file | `status` is safe to retry. For `reset`: get the user's approval, then re-run with `--yes` |
|
||||||
| `eval sessions` | Never fails; an empty list is a valid answer | Read-only | - |
|
| `eval sessions` | Never fails; an empty list is a valid answer | Read-only | - |
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ tools/
|
|||||||
links.py labelled edges in `related:` - the graph's semantics as data, not prose
|
links.py labelled edges in `related:` - the graph's semantics as data, not prose
|
||||||
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
|
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
|
||||||
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
|
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
|
||||||
|
ownership.py the stack-vs-instance boundary under a content stage - one predicate, read by `dist_cmd.py` and `commands/upstream_cmd.py` so the two cannot answer it differently
|
||||||
type_resolver.py type-spec loading and schema resolution
|
type_resolver.py type-spec loading and schema resolution
|
||||||
lint_core.py the lint checks and the report, with no CLI attached
|
lint_core.py the lint checks and the report, with no CLI attached
|
||||||
types_core.py type-spec listing/description, with no CLI attached
|
types_core.py type-spec listing/description, with no CLI attached
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ try:
|
|||||||
search as search_module,
|
search as search_module,
|
||||||
touch as touch_module,
|
touch as touch_module,
|
||||||
types_cmd,
|
types_cmd,
|
||||||
|
upstream_cmd,
|
||||||
version_cmd,
|
version_cmd,
|
||||||
work_cmd,
|
work_cmd,
|
||||||
xref,
|
xref,
|
||||||
@@ -69,6 +70,7 @@ app.add_typer(eval_cmd.app, name="eval")
|
|||||||
app.add_typer(dist_cmd.app, name="dist")
|
app.add_typer(dist_cmd.app, name="dist")
|
||||||
app.add_typer(version_cmd.app, name="version")
|
app.add_typer(version_cmd.app, name="version")
|
||||||
app.add_typer(migrate_cmd.app, name="migrate")
|
app.add_typer(migrate_cmd.app, name="migrate")
|
||||||
|
app.add_typer(upstream_cmd.app, name="upstream")
|
||||||
app.command("new")(new_page.new_page_command)
|
app.command("new")(new_page.new_page_command)
|
||||||
app.command("touch")(touch_module.touch_command)
|
app.command("touch")(touch_module.touch_command)
|
||||||
app.command("rename")(page_ops.rename_command)
|
app.command("rename")(page_ops.rename_command)
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ Keeping the undecayed anchor in `confidence_base` is what makes repeated runs
|
|||||||
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
|
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
|
||||||
behavior) compounded on every run, because the elapsed-months factor kept
|
behavior) compounded on every run, because the elapsed-months factor kept
|
||||||
growing while the multiplicand had already shrunk.
|
growing while the multiplicand had already shrunk.
|
||||||
|
|
||||||
|
Pages with `concept_type: decision` are skipped structurally, not as an
|
||||||
|
interim measure. The formula models staleness - a claim that nobody has
|
||||||
|
re-checked in a while becomes less trustworthy - and a decision is not a
|
||||||
|
claim about the world that time can falsify. What retires a decision is a
|
||||||
|
later decision superseding it, never elapsed months on its own; that is a
|
||||||
|
category the decay formula does not have a term for, so it does not apply
|
||||||
|
one.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -118,6 +126,8 @@ def confidence_decay(
|
|||||||
missing_base = []
|
missing_base = []
|
||||||
|
|
||||||
for title, page in sorted(pages.items()):
|
for title, page in sorted(pages.items()):
|
||||||
|
if page.frontmatter.get("concept_type") == "decision":
|
||||||
|
continue
|
||||||
confidence = page.frontmatter.get("confidence")
|
confidence = page.frontmatter.get("confidence")
|
||||||
if confidence is None:
|
if confidence is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -33,14 +33,20 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, NamedTuple, Optional, Union
|
from typing import Callable, NamedTuple, Optional, Union
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import config, conventions, kb_collections, kb_state, version as version_mod
|
from chemenu import config, conventions, kb_collections, kb_state, ownership, version as version_mod
|
||||||
from chemenu.commands._util import fail, rel_path, success, today_iso
|
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||||
|
|
||||||
app = typer.Typer(help="Build a distributable copy of the wiki machinery.")
|
app = typer.Typer(help="Build a distributable copy of the wiki machinery.")
|
||||||
|
|
||||||
@@ -73,6 +79,15 @@ DIST_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "dist_templates"
|
|||||||
# read server is part of what an instance *has*, even though its dependency is
|
# read server is part of what an instance *has*, even though its dependency is
|
||||||
# optional. A distribution whose server is present but undocumented is one
|
# optional. A distribution whose server is present but undocumented is one
|
||||||
# whose operator finds the module by reading the source.
|
# whose operator finds the module by reading the source.
|
||||||
|
#
|
||||||
|
# `DEVELOPMENT.md` is deliberately **absent** from this tuple, unlike every
|
||||||
|
# other root doc above. It documents the release workflow (`version bump` ->
|
||||||
|
# `version release` -> `publish` -> CI tags) and points at `instructions/dev/`,
|
||||||
|
# which this same function excludes wholesale a few lines down - a distributed
|
||||||
|
# instance has no release workflow, no CI and no issue board, so it has
|
||||||
|
# nothing for that document to describe. Do not "fix" this by adding it back:
|
||||||
|
# a root file absent from ROOT_FILES is silently skipped by every export, and
|
||||||
|
# that silence is the correct behaviour here, not a gap.
|
||||||
ROOT_FILES = (
|
ROOT_FILES = (
|
||||||
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
||||||
".gitignore", "VERSION",
|
".gitignore", "VERSION",
|
||||||
@@ -123,8 +138,15 @@ INSTRUCTIONS_EXCLUDE_DIRS = {"dev"}
|
|||||||
RAW_SUBDIRS = ("articles", "documents", "notes", "assets")
|
RAW_SUBDIRS = ("articles", "documents", "notes", "assets")
|
||||||
|
|
||||||
# Stage contracts that are not collections and carry no pages: copied as a
|
# Stage contracts that are not collections and carry no pages: copied as a
|
||||||
# single file each, nothing else from their directory.
|
# single file each, nothing else from their directory. `kb/` is excluded here
|
||||||
CONTRACT_ONLY_STAGES = ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md")
|
# - it is a content stage too, but it has collections underneath it, so its
|
||||||
|
# contract is handled by `build_plan` alongside them rather than as a bare
|
||||||
|
# stage copy. Derived from `ownership.CONTENT_STAGES` rather than listed
|
||||||
|
# again, so the set this loop copies and the set `upstream merge` restores
|
||||||
|
# cannot name a different stage without one of them failing its own test.
|
||||||
|
CONTRACT_ONLY_STAGES = tuple(
|
||||||
|
f"{stage}/CONTRACT.md" for stage in ownership.CONTENT_STAGES if stage != "kb"
|
||||||
|
)
|
||||||
|
|
||||||
# Single tracked files copied out of an otherwise-untouched, partially-ignored
|
# Single tracked files copied out of an otherwise-untouched, partially-ignored
|
||||||
# directory. `.claude/` holds the harness's own session-tracing config
|
# directory. `.claude/` holds the harness's own session-tracing config
|
||||||
@@ -347,6 +369,12 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
|||||||
for hook_dir in HOOK_DIRS:
|
for hook_dir in HOOK_DIRS:
|
||||||
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
|
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
|
||||||
|
|
||||||
|
# docs/ is stack background - why the stack is built the way it is - and
|
||||||
|
# ships verbatim like instructions/ and types/: it carries no page, no
|
||||||
|
# frontmatter, and (AGENTS.md § File naming) no normative sentence, so
|
||||||
|
# there is nothing instance-owned in it to split off as a .template.
|
||||||
|
plan.update(_copy_tree(config.ROOT / "docs", "docs", frozenset()))
|
||||||
|
|
||||||
# `kb/CONTRACT.md` is stack-owned and ships verbatim; everything beside it
|
# `kb/CONTRACT.md` is stack-owned and ships verbatim; everything beside it
|
||||||
# under `kb/` is the instance's own and ships only as a `.template`. That is
|
# under `kb/` is the instance's own and ships only as a `.template`. That is
|
||||||
# the personalization split (`USER.md`/`SOUL.md`) one directory down, and
|
# the personalization split (`USER.md`/`SOUL.md`) one directory down, and
|
||||||
@@ -394,8 +422,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
|||||||
# machinery expects - which is exactly what makes the initial declaration
|
# machinery expects - which is exactly what makes the initial declaration
|
||||||
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
||||||
# instance predating this file has to answer that question by hand.
|
# instance predating this file has to answer that question by hand.
|
||||||
|
#
|
||||||
|
# `.base`, not the raw `VERSION`: a content shape has no beta channel
|
||||||
|
# (`kb_state.read_kb_version` refuses one), so exporting mid-candidate
|
||||||
|
# still declares the release the content is shaped for, not the candidate
|
||||||
|
# in progress. The stamp below carries the honest, suffix-inclusive value -
|
||||||
|
# the two files answer different questions.
|
||||||
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
||||||
kb_state.render_kb_state(version_mod.read_version(), [])
|
kb_state.render_kb_state(version_mod.read_version().base, [])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Last, so it can digest everything above it. It is the one file in the
|
# Last, so it can digest everything above it. It is the one file in the
|
||||||
@@ -418,19 +452,20 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
|||||||
# appears in INSTALL.md and version.py, so such a scan would either whitelist
|
# appears in INSTALL.md and version.py, so such a scan would either whitelist
|
||||||
# the very string it is looking for or cry wolf on every export.
|
# the very string it is looking for or cry wolf on every export.
|
||||||
#
|
#
|
||||||
# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* on the allowed
|
# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* allowed through
|
||||||
# list any more. Both bind, and both are the instance's to write, so they cross
|
# any more. Both bind, and both are the instance's to write, so they cross the
|
||||||
# the boundary as `.template` and are adopted by a rename - a plan carrying the
|
# boundary as `.template` and are adopted by a rename - a plan carrying the
|
||||||
# filled name would hand a new instance this one's authoring conventions as
|
# filled name would hand a new instance this one's authoring conventions as
|
||||||
# though they were the stack's.
|
# though they were the stack's.
|
||||||
|
#
|
||||||
|
# What counts as machinery under kb/ or raw/ is no longer a second list here:
|
||||||
|
# it is `ownership.is_stack_owned`, the same predicate `upstream merge` and
|
||||||
|
# `upstream verify` restore/check against. Only the export-only stubs
|
||||||
|
# (`ownership.EXPORT_STUB_NAMES`) are allowed here without also being
|
||||||
|
# stack-owned - a merge keeps the *local* copy of those, while export writes a
|
||||||
|
# fresh one regardless of either side, so the two callers genuinely disagree
|
||||||
|
# about them and each keeps its own allowance for that one case.
|
||||||
_CONTENT_PREFIXES = ("kb/", "raw/")
|
_CONTENT_PREFIXES = ("kb/", "raw/")
|
||||||
_CONTENT_ALLOWED_NAMES = (
|
|
||||||
"CONTRACT.md",
|
|
||||||
f"{kb_collections.CONTRACT_NAME}.template",
|
|
||||||
conventions.CONVENTIONS_TEMPLATE,
|
|
||||||
"log.md",
|
|
||||||
".gitkeep",
|
|
||||||
)
|
|
||||||
_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME)
|
_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
@@ -452,7 +487,11 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
|
|||||||
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
|
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
|
||||||
elif relative.startswith("instructions/dev/"):
|
elif relative.startswith("instructions/dev/"):
|
||||||
leaks.append(f"{relative} (stack-development only)")
|
leaks.append(f"{relative} (stack-development only)")
|
||||||
elif relative.startswith(_CONTENT_PREFIXES) and name not in _CONTENT_ALLOWED_NAMES:
|
elif (
|
||||||
|
relative.startswith(_CONTENT_PREFIXES)
|
||||||
|
and not ownership.is_stack_owned(relative)
|
||||||
|
and not ownership.is_export_stub(name)
|
||||||
|
):
|
||||||
leaks.append(f"{relative} (wiki content, not machinery)")
|
leaks.append(f"{relative} (wiki content, not machinery)")
|
||||||
return leaks
|
return leaks
|
||||||
|
|
||||||
@@ -492,7 +531,8 @@ def export_command(
|
|||||||
):
|
):
|
||||||
"""Export a contentless, distributable copy of this repo's machinery:
|
"""Export a contentless, distributable copy of this repo's machinery:
|
||||||
AGENTS.md/README.md (dev-instance-only marker blocks removed),
|
AGENTS.md/README.md (dev-instance-only marker blocks removed),
|
||||||
instructions/ (no instructions/dev/), types/, tools/ (no venv/caches),
|
instructions/ (no instructions/dev/), types/, docs/ verbatim,
|
||||||
|
tools/ (no venv/caches),
|
||||||
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
|
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
|
||||||
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
|
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
|
||||||
kb/CONVENTIONS.md.template (no pages, no areas), empty
|
kb/CONVENTIONS.md.template (no pages, no areas), empty
|
||||||
@@ -547,3 +587,405 @@ def run_export(target: Path, dry_run: bool = False, origin: Optional[Origin] = N
|
|||||||
|
|
||||||
_write_plan(target, plan)
|
_write_plan(target, plan)
|
||||||
success(f"Exported {len(plan)} file(s) to {rel_path(target)}.")
|
success(f"Exported {len(plan)} file(s) to {rel_path(target)}.")
|
||||||
|
|
||||||
|
|
||||||
|
# --- dist upgrade ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Apply a release `dist export` produced, rather than merely detecting one
|
||||||
|
# (`version check`). Gitea #7 has the full design; the short version: the
|
||||||
|
# write set is exactly the *new* stamp's `files` block, minus the paths an
|
||||||
|
# export seeds once and the instance owns from then on
|
||||||
|
# (`ownership.is_export_stub`, `ownership.is_upgrade_preserved`), plus the
|
||||||
|
# stamp itself. Every candidate path is classified against the *old* stamp's
|
||||||
|
# recorded digest - unchanged, locally modified, or locally deleted - and a
|
||||||
|
# modified/deleted file is never silently overwritten. This never calls a
|
||||||
|
# release feed; the caller supplies an already-downloaded tree or archive.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileClassification:
|
||||||
|
"""The four-way split of every path `dist upgrade` would touch, plus the
|
||||||
|
fifth direction (`removed`) that has no write set of its own."""
|
||||||
|
|
||||||
|
unchanged: list[str]
|
||||||
|
modified: list[str]
|
||||||
|
deleted: list[str]
|
||||||
|
new: list[str]
|
||||||
|
removed: list[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked(self) -> list[str]:
|
||||||
|
"""Locally changed paths - modified or deleted - which are never
|
||||||
|
silently overwritten."""
|
||||||
|
return sorted(self.modified + self.deleted)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_candidates(new_files: dict) -> set[str]:
|
||||||
|
"""Every path `dist upgrade` may write, from the new stamp's `files`
|
||||||
|
block: everything except the paths an export re-seeds from a blank
|
||||||
|
template every time (`ownership.is_export_stub`) and the paths an export
|
||||||
|
seeds once and the instance owns afterward (`ownership.is_upgrade_preserved`).
|
||||||
|
The release stamp itself is added separately - it is never a member of its
|
||||||
|
own `files` block, see `build_stamp`."""
|
||||||
|
return {
|
||||||
|
relative
|
||||||
|
for relative in new_files
|
||||||
|
if not ownership.is_export_stub(Path(relative).name)
|
||||||
|
and not ownership.is_upgrade_preserved(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_files(old_files: dict, new_files: dict) -> FileClassification:
|
||||||
|
candidates = _write_candidates(new_files)
|
||||||
|
recorded_for_candidates = {r: d for r, d in old_files.items() if r in candidates}
|
||||||
|
statuses = kb_state.compare_against_stamp(recorded_for_candidates)
|
||||||
|
|
||||||
|
unchanged: list[str] = []
|
||||||
|
modified: list[str] = []
|
||||||
|
deleted: list[str] = []
|
||||||
|
new: list[str] = []
|
||||||
|
for relative in sorted(candidates):
|
||||||
|
if relative not in old_files:
|
||||||
|
new.append(relative)
|
||||||
|
continue
|
||||||
|
status = statuses[relative]
|
||||||
|
if status == kb_state.UNCHANGED:
|
||||||
|
unchanged.append(relative)
|
||||||
|
elif status == kb_state.MODIFIED:
|
||||||
|
modified.append(relative)
|
||||||
|
else:
|
||||||
|
deleted.append(relative)
|
||||||
|
|
||||||
|
removed = sorted(set(old_files) - set(new_files))
|
||||||
|
return FileClassification(unchanged, modified, deleted, new, removed)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_sha256_sidecar(archive: Path) -> None:
|
||||||
|
"""WARN, never fail, on a missing sidecar - only a corrupted one that
|
||||||
|
*is* present is a reason to stop, per Gitea #7's design table."""
|
||||||
|
sidecar = archive.with_name(archive.name + ".sha256")
|
||||||
|
if not sidecar.is_file():
|
||||||
|
console.print(
|
||||||
|
f"[yellow]WARN[/yellow] No {sidecar.name} beside {archive.name} - the archive's "
|
||||||
|
"integrity is not being checked before it is extracted."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
expected = sidecar.read_text(encoding="utf-8").strip().split()[0:1]
|
||||||
|
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||||
|
if not expected or expected[0].lower() != actual.lower():
|
||||||
|
fail(
|
||||||
|
f"{archive.name} does not match {sidecar.name}: expected "
|
||||||
|
f"{expected[0] if expected else '(unreadable)'}, got {actual}. Re-download the "
|
||||||
|
"release archive rather than trusting one that failed its own checksum."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_single_top_level_dir(archive: Path, dest: Path) -> Path:
|
||||||
|
"""Extract `archive` into `dest` and return the one top-level directory it
|
||||||
|
contained - the shape `.gitea/workflows/release.yml` packs (see its
|
||||||
|
`Build the distribution tarball` step). Refuses anything else rather than
|
||||||
|
guessing which part is the machinery."""
|
||||||
|
with tarfile.open(archive) as tf:
|
||||||
|
names = [n for n in tf.getnames() if n not in ("", ".")]
|
||||||
|
top_levels = {n.split("/", 1)[0] for n in names}
|
||||||
|
if len(top_levels) != 1:
|
||||||
|
fail(
|
||||||
|
f"{archive.name} does not have exactly one top-level directory (found "
|
||||||
|
f"{len(top_levels)}: {', '.join(sorted(top_levels)) or '(empty archive)'}) - this "
|
||||||
|
"is not the shape a release tarball has, and `dist upgrade` refuses to guess "
|
||||||
|
"which part is the machinery."
|
||||||
|
)
|
||||||
|
return dest # unreachable: fail() raises typer.Exit
|
||||||
|
try:
|
||||||
|
tf.extractall(dest, filter="data") # noqa: S202 - trusted local archive, path-checked below
|
||||||
|
except TypeError:
|
||||||
|
# Python < 3.12 has no `filter=` argument. Same guard by hand:
|
||||||
|
# refuse any member whose extracted path would land outside dest.
|
||||||
|
resolved_dest = dest.resolve()
|
||||||
|
for member in tf.getmembers():
|
||||||
|
if not (resolved_dest / member.name).resolve().is_relative_to(resolved_dest):
|
||||||
|
fail(
|
||||||
|
f"{archive.name} contains a path that escapes the extraction directory: "
|
||||||
|
f"{member.name}"
|
||||||
|
)
|
||||||
|
return dest # unreachable
|
||||||
|
tf.extractall(dest) # noqa: S202 - every member path-checked above
|
||||||
|
return dest / next(iter(top_levels))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _resolved_source(source: Path):
|
||||||
|
"""Yield the directory holding a distribution export: `source` itself if
|
||||||
|
it already is one, or the single top-level directory of a `.tar.gz`
|
||||||
|
extracted into a scratch directory that is cleaned up afterward."""
|
||||||
|
if source.is_dir():
|
||||||
|
yield source
|
||||||
|
return
|
||||||
|
if not source.is_file():
|
||||||
|
fail(f"{source} does not exist.")
|
||||||
|
return
|
||||||
|
_verify_sha256_sidecar(source)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="wikitool-upgrade-") as tmp:
|
||||||
|
yield _extract_single_top_level_dir(source, Path(tmp))
|
||||||
|
|
||||||
|
|
||||||
|
def _git_working_tree_status() -> Optional[str]:
|
||||||
|
"""`git status --porcelain` for `config.ROOT`, or None if it is not a git
|
||||||
|
repository at all - which is a valid, if unprotected, state for a tarball
|
||||||
|
instance, not a reason to refuse."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(config.ROOT), "status", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _report_plan(
|
||||||
|
classification: FileClassification,
|
||||||
|
migration_chain: list["kb_state.Migration"],
|
||||||
|
boundary_crossing: bool,
|
||||||
|
local_version: "version_mod.Version",
|
||||||
|
new_version: "version_mod.Version",
|
||||||
|
) -> None:
|
||||||
|
console.print(f"{local_version} -> {new_version}")
|
||||||
|
if boundary_crossing:
|
||||||
|
console.print(
|
||||||
|
f"[bold yellow]Crosses a compatibility boundary[/bold yellow] "
|
||||||
|
f"({local_version.compat_key} -> {new_version.compat_key}) - this is not a drop-in "
|
||||||
|
"swap; check the release notes before proceeding."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"{len(classification.unchanged)} unchanged, {len(classification.new)} new, "
|
||||||
|
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
|
||||||
|
"from the release."
|
||||||
|
)
|
||||||
|
if classification.modified:
|
||||||
|
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
|
||||||
|
for relative in classification.modified:
|
||||||
|
console.print(f" - {relative}")
|
||||||
|
if classification.deleted:
|
||||||
|
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
|
||||||
|
for relative in classification.deleted:
|
||||||
|
console.print(f" - {relative}")
|
||||||
|
if classification.removed:
|
||||||
|
console.print("[dim]No longer part of the release, not written or removed by default:[/dim]")
|
||||||
|
for relative in classification.removed:
|
||||||
|
console.print(f" [dim]- {relative}[/dim]")
|
||||||
|
if migration_chain:
|
||||||
|
console.print(
|
||||||
|
f"[cyan]{len(migration_chain)} migration(s) will be outstanding after this "
|
||||||
|
"upgrade, in this order:[/cyan]"
|
||||||
|
)
|
||||||
|
for position, migration in enumerate(migration_chain, start=1):
|
||||||
|
console.print(f" {position}. {migration.target} {migration.name} ({migration.kind})")
|
||||||
|
console.print("Report only - `dist upgrade` never runs a migration. See `wikitool migrate status`.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("upgrade")
|
||||||
|
def upgrade_command(
|
||||||
|
source: Path = typer.Argument(
|
||||||
|
..., help="An extracted distribution directory, or a release .tar.gz archive"
|
||||||
|
),
|
||||||
|
dry_run: bool = typer.Option(
|
||||||
|
False, "--dry-run", help="Classify and report, without writing anything"
|
||||||
|
),
|
||||||
|
keep_local: bool = typer.Option(
|
||||||
|
False, "--keep-local",
|
||||||
|
help="Proceed even with locally changed files - leave each one untouched rather than aborting",
|
||||||
|
),
|
||||||
|
prune: bool = typer.Option(
|
||||||
|
False, "--prune",
|
||||||
|
help="Also delete files the new release no longer ships, if they are unchanged since install",
|
||||||
|
),
|
||||||
|
allow_pre: bool = typer.Option(
|
||||||
|
False, "--pre", help="Allow a pre-release (-beta.N) source tree - release.yml never publishes one",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Apply a stack update `dist export` produced - the write half of
|
||||||
|
`version check`. Never downloads anything: `source` is an already-fetched
|
||||||
|
export directory or `.tar.gz` archive. Writes exactly the new release
|
||||||
|
stamp's `files` block, minus what an export re-seeds every time
|
||||||
|
(`kb/log.md`, `raw/*/.gitkeep`) or seeds once and the instance owns from
|
||||||
|
then on (`.wikitool-kb.json`, `CHANGES.md`), classifying every candidate
|
||||||
|
against the *old* stamp's recorded digest: unchanged files are
|
||||||
|
overwritten silently, new files are created, and a locally modified or
|
||||||
|
deleted file is never silently overwritten - `dist upgrade` aborts unless
|
||||||
|
`--keep-local` says to leave it alone. Reports the migration chain the new
|
||||||
|
machinery would owe without running any of it (there is no `migrate run`).
|
||||||
|
Refuses on a missing local release stamp, a downgrade, a pre-release
|
||||||
|
source without `--pre`, or a dirty working tree. Never touches git.
|
||||||
|
See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren"."""
|
||||||
|
run_upgrade(
|
||||||
|
source, dry_run=dry_run, keep_local=keep_local, prune=prune, allow_pre=allow_pre
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_upgrade(
|
||||||
|
source: Path,
|
||||||
|
dry_run: bool = False,
|
||||||
|
keep_local: bool = False,
|
||||||
|
prune: bool = False,
|
||||||
|
allow_pre: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""The upgrade itself, free of Typer's option objects - see `run_export`
|
||||||
|
for why this split exists."""
|
||||||
|
try:
|
||||||
|
local_version = version_mod.read_version()
|
||||||
|
except version_mod.VersionError as exc:
|
||||||
|
fail(f"{exc} - this tree has no stack version to upgrade from.")
|
||||||
|
return
|
||||||
|
|
||||||
|
old_stamp = version_mod.read_stamp()
|
||||||
|
if not old_stamp or not isinstance(old_stamp.get("files"), dict):
|
||||||
|
fail(
|
||||||
|
f"No local {version_mod.RELEASE_STAMP_FILENAME} (or it carries no `files` block). "
|
||||||
|
"Without it, `dist upgrade` cannot tell a file this instance edited from one it "
|
||||||
|
"merely received, and it refuses to guess. A checkout with shared git history takes "
|
||||||
|
"stack updates via `wikitool upstream merge` instead - it has the same information "
|
||||||
|
"as a merge base. A tarball instance that has lost its stamp has no repair path "
|
||||||
|
"today; see Gitea #7 \"Bewusst offen gelassen\"."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
old_files = old_stamp["files"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
kb_version = kb_state.read_kb_version()
|
||||||
|
except version_mod.VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
if kb_version is None:
|
||||||
|
fail(
|
||||||
|
f"{kb_state.KB_STATE_FILENAME} is missing - this instance has never declared what "
|
||||||
|
"shape its content is in. Run `wikitool migrate baseline <version>` before upgrading."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
outstanding = kb_state.chain(kb_state.load_migrations(), kb_version, local_version.base)
|
||||||
|
if outstanding:
|
||||||
|
fail(
|
||||||
|
f"{len(outstanding)} migration(s) are already outstanding against the installed "
|
||||||
|
f"machinery ({kb_version} -> {local_version}) - `wikitool migrate status` names them. "
|
||||||
|
"Finish them before upgrading further: a machinery swap on top of an unfinished "
|
||||||
|
"migration leaves the corpus in a shape no version describes."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
tree_status = _git_working_tree_status()
|
||||||
|
if tree_status is None:
|
||||||
|
console.print(
|
||||||
|
"[yellow]WARN[/yellow] Not a git repository (or git is unavailable) - proceeding "
|
||||||
|
"without the dirty-tree check a repository would get."
|
||||||
|
)
|
||||||
|
elif tree_status.strip():
|
||||||
|
fail(
|
||||||
|
"Working tree is not clean (`git status --porcelain` printed something). "
|
||||||
|
"`dist upgrade` refuses to start on a dirty tree so a refusal never has to guess "
|
||||||
|
"which changes were already there. Commit or stash first."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with _resolved_source(source) as new_root:
|
||||||
|
version_path = new_root / version_mod.VERSION_FILENAME
|
||||||
|
if not version_path.is_file():
|
||||||
|
fail(f"{rel_path(new_root)} has no VERSION - not a distribution export.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
new_version = version_mod.Version.parse(version_path.read_text(encoding="utf-8"))
|
||||||
|
except version_mod.VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_version.is_prerelease and not allow_pre:
|
||||||
|
fail(
|
||||||
|
f"{new_version} is a running candidate (-beta.N). `.gitea/workflows/release.yml` "
|
||||||
|
"never publishes one, so a candidate tree can only come from a dev checkout by "
|
||||||
|
"hand - pass --pre if that is deliberate."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if new_version < local_version:
|
||||||
|
fail(f"{new_version} is older than the installed {local_version} - refusing a downgrade.")
|
||||||
|
return
|
||||||
|
if new_version == local_version:
|
||||||
|
success(f"Already at {local_version}. Nothing to do.")
|
||||||
|
return
|
||||||
|
|
||||||
|
stamp_path = new_root / version_mod.RELEASE_STAMP_FILENAME
|
||||||
|
if not stamp_path.is_file():
|
||||||
|
fail(f"{rel_path(new_root)} has no {version_mod.RELEASE_STAMP_FILENAME} - not a distribution export.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
new_stamp = json.loads(stamp_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
fail(f"{version_mod.RELEASE_STAMP_FILENAME} in the source is not readable JSON: {exc}")
|
||||||
|
return
|
||||||
|
new_files = new_stamp.get("files") if isinstance(new_stamp, dict) else None
|
||||||
|
if not isinstance(new_files, dict):
|
||||||
|
fail(f"{version_mod.RELEASE_STAMP_FILENAME} in the source carries no `files` block.")
|
||||||
|
return
|
||||||
|
|
||||||
|
classification = _classify_files(old_files, new_files)
|
||||||
|
migration_chain = kb_state.chain(
|
||||||
|
kb_state.load_migrations(new_root / "instructions" / kb_state.MIGRATIONS_SUBDIR),
|
||||||
|
kb_version,
|
||||||
|
new_version.base,
|
||||||
|
)
|
||||||
|
boundary_crossing = local_version.compat_key != new_version.compat_key
|
||||||
|
|
||||||
|
_report_plan(classification, migration_chain, boundary_crossing, local_version, new_version)
|
||||||
|
|
||||||
|
# Dry-run's whole purpose is to preview this classification - including
|
||||||
|
# the blocked list - without raising, so it must be checked before the
|
||||||
|
# abort below rather than after: a blocked file must never turn
|
||||||
|
# `--dry-run` into a non-zero exit, or the flag stops being safe to run
|
||||||
|
# freely.
|
||||||
|
if dry_run:
|
||||||
|
success(f"Dry run: would upgrade {local_version} -> {new_version}. Nothing written.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if classification.blocked and not keep_local:
|
||||||
|
fail(
|
||||||
|
f"{len(classification.blocked)} locally changed file(s) (listed above) would be "
|
||||||
|
"silently overwritten. Pass --keep-local to upgrade anyway and leave every one of "
|
||||||
|
"them untouched, or reconcile them by hand first. Nothing was written."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
to_write = sorted(classification.unchanged + classification.new)
|
||||||
|
for relative in to_write:
|
||||||
|
src = new_root / relative
|
||||||
|
dst = config.ROOT / relative
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
shutil.copy2(stamp_path, config.ROOT / version_mod.RELEASE_STAMP_FILENAME)
|
||||||
|
|
||||||
|
pruned: list[str] = []
|
||||||
|
if prune:
|
||||||
|
for relative in classification.removed:
|
||||||
|
digest = old_files.get(relative)
|
||||||
|
if digest is None:
|
||||||
|
continue
|
||||||
|
status = kb_state.compare_against_stamp({relative: digest}).get(relative)
|
||||||
|
if status != kb_state.UNCHANGED:
|
||||||
|
continue
|
||||||
|
target = config.ROOT / relative
|
||||||
|
if target.is_file():
|
||||||
|
target.unlink()
|
||||||
|
pruned.append(relative)
|
||||||
|
|
||||||
|
skipped = classification.blocked if keep_local else []
|
||||||
|
summary = (
|
||||||
|
f"Upgraded {local_version} -> {new_version}: {len(to_write)} file(s) written"
|
||||||
|
+ (f", {len(skipped)} left untouched (--keep-local)" if skipped else "")
|
||||||
|
+ (f", {len(pruned)} pruned" if pruned else "")
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
if migration_chain:
|
||||||
|
summary += (
|
||||||
|
f" {len(migration_chain)} migration(s) now outstanding - run `wikitool migrate status`."
|
||||||
|
)
|
||||||
|
summary += (
|
||||||
|
" Nothing was committed. Now run, in order: `wikitool instructions sync`, `doctor`, "
|
||||||
|
"`docs verify`, `instructions verify`, `lint` - then restart the agent session."
|
||||||
|
)
|
||||||
|
success(summary)
|
||||||
|
|||||||
@@ -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
|
# 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
|
# same reason: it is human-facing prose about installing an instance, and the
|
||||||
# command reference lives exactly once, in tools/CONTRACT.md.
|
# 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.
|
# 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
|
# The per-collection contracts are appended at call time, since which ones exist
|
||||||
@@ -453,10 +463,12 @@ def check_version_changelog() -> list[str]:
|
|||||||
|
|
||||||
This is the check that makes `version bump` more than a convenience: a
|
This is the check that makes `version bump` more than a convenience: a
|
||||||
version raised with nothing written about it would ship a release whose
|
version raised with nothing written about it would ship a release whose
|
||||||
notes describe the previous one. A changelog with *no* versioned entry at
|
notes describe the previous one. `VERSION` may name a running candidate
|
||||||
all is fine - that is a fresh distribution, and this repo's own pre-
|
(`-beta.N`) rather than a release - `Version.parse`/equality read the
|
||||||
versioning history, neither of which claims to describe the current
|
suffix like any other component, so a candidate is compared exactly like a
|
||||||
version.
|
release here. A changelog with *no* versioned entry at all is fine - that
|
||||||
|
is a fresh distribution, and this repo's own pre-versioning history,
|
||||||
|
neither of which claims to describe the current version.
|
||||||
"""
|
"""
|
||||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||||
if not version_path.is_file():
|
if not version_path.is_file():
|
||||||
@@ -483,15 +495,6 @@ def check_version_changelog() -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
|
|
||||||
"""The version named by the second-newest versioned entry, or None."""
|
|
||||||
seen = [
|
|
||||||
version_mod.Version.parse(match.group(1))
|
|
||||||
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
|
|
||||||
]
|
|
||||||
return seen[1] if len(seen) > 1 else None
|
|
||||||
|
|
||||||
|
|
||||||
def check_migration_for_boundary() -> list[str]:
|
def check_migration_for_boundary() -> list[str]:
|
||||||
"""A version that crosses the compatibility boundary must say how to cross it.
|
"""A version that crosses the compatibility boundary must say how to cross it.
|
||||||
|
|
||||||
@@ -501,9 +504,12 @@ def check_migration_for_boundary() -> list[str]:
|
|||||||
document targeting it, or an explicit statement in its changelog entry that
|
document targeting it, or an explicit statement in its changelog entry that
|
||||||
no content has to change.
|
no content has to change.
|
||||||
|
|
||||||
Only the newest entry is checked. Older boundaries were either satisfied
|
Only the newest entry is checked, against the **last release** rather than
|
||||||
when they were written or cannot be fixed retroactively, and re-reporting
|
the entry beneath it - between two candidates of the same running upgrade
|
||||||
them forever would make the check noise.
|
(`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and
|
||||||
|
comparing to the entry beneath would find none even when the candidate
|
||||||
|
genuinely crosses one relative to what is actually installed anywhere. See
|
||||||
|
instructions/dev/version-parts.md.
|
||||||
"""
|
"""
|
||||||
from chemenu import kb_state
|
from chemenu import kb_state
|
||||||
|
|
||||||
@@ -514,15 +520,15 @@ def check_migration_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
text = changes_path.read_text(encoding="utf-8")
|
text = changes_path.read_text(encoding="utf-8")
|
||||||
current = version_mod.top_changes_version(text)
|
current = version_mod.top_changes_version(text)
|
||||||
previous = _second_changes_version(text)
|
previous = version_mod.last_release(text)
|
||||||
if current is None or previous is None:
|
if current is None or previous is None:
|
||||||
return [] # the first versioned entry has no predecessor to cross from
|
return [] # no release recorded yet to cross from (fresh distribution)
|
||||||
if current.compat_key == previous.compat_key:
|
if current.compat_key == previous.compat_key:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
||||||
return []
|
return []
|
||||||
if any(m.target == current for m in kb_state.load_migrations()):
|
if any(m.target == current.base for m in kb_state.load_migrations()):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -544,8 +550,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
import name or flag - satisfies that check and still leaves every existing
|
import name or flag - satisfies that check and still leaves every existing
|
||||||
instance with something to do by hand.
|
instance with something to do by hand.
|
||||||
|
|
||||||
Only the newest entry is checked, for the same reason: older crossings are
|
Only the newest entry is checked, against the **last release** - see
|
||||||
history, and re-reporting them forever would make the check noise.
|
`check_migration_for_boundary` for why the entry beneath it is the wrong
|
||||||
|
comparison once a candidate can span more than one bump.
|
||||||
"""
|
"""
|
||||||
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
||||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||||
@@ -554,9 +561,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
text = changes_path.read_text(encoding="utf-8")
|
text = changes_path.read_text(encoding="utf-8")
|
||||||
current = version_mod.top_changes_version(text)
|
current = version_mod.top_changes_version(text)
|
||||||
previous = _second_changes_version(text)
|
previous = version_mod.last_release(text)
|
||||||
if current is None or previous is None:
|
if current is None or previous is None:
|
||||||
return [] # the first versioned entry has no predecessor to cross from
|
return [] # no release recorded yet to cross from (fresh distribution)
|
||||||
if current.compat_key == previous.compat_key:
|
if current.compat_key == previous.compat_key:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,8 @@ def check_stack_version() -> Check:
|
|||||||
)
|
)
|
||||||
|
|
||||||
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
||||||
return Check("stack-version", "OK", f"{current} ({origin})")
|
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
|
||||||
|
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
|
||||||
|
|
||||||
|
|
||||||
def check_kb_version() -> Check:
|
def check_kb_version() -> Check:
|
||||||
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
|
|||||||
"never lagged behind its machinery",
|
"never lagged behind its machinery",
|
||||||
)
|
)
|
||||||
if kb_version < stack:
|
if kb_version < stack:
|
||||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack)
|
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
|
||||||
if pending:
|
if pending:
|
||||||
return Check(
|
return Check(
|
||||||
"kb-version", "WARN",
|
"kb-version", "WARN",
|
||||||
|
|||||||
@@ -423,6 +423,46 @@ def counted_files_of(
|
|||||||
return [change for change in changes if not is_exempt(change.path, prefixes)]
|
return [change for change in changes if not is_exempt(change.path, prefixes)]
|
||||||
|
|
||||||
|
|
||||||
|
# The same scope `version-parts.md` names for the stack version itself -
|
||||||
|
# "tools/, types/, instructions/, AGENTS.md and the contracts" - reused here
|
||||||
|
# to decide whether a publish's changeset falls under it. Not a copy of that
|
||||||
|
# rule: version-parts.md states the scope in prose for a human choosing a
|
||||||
|
# version part, this instantiates the same boundary in code for a different
|
||||||
|
# question (does this publish deserve the closing-phase reminder below).
|
||||||
|
STACK_MACHINERY_PREFIXES = ("tools/", "types/", "instructions/")
|
||||||
|
STACK_MACHINERY_NAMES = ("AGENTS.md",)
|
||||||
|
|
||||||
|
STACK_MACHINERY_NOTE = (
|
||||||
|
"Note: this publish touched stack machinery. What a stack-dev session "
|
||||||
|
"does next - closing prose, a changelog entry's accuracy, whether a "
|
||||||
|
"docs/ page went stale - is not covered by docs verify, instructions "
|
||||||
|
"verify, or pytest. No tool checks it; a session has to."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def touches_stack_machinery(changed_files: list[str]) -> bool:
|
||||||
|
"""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
|
||||||
|
if path.endswith("CONTRACT.md"):
|
||||||
|
return True
|
||||||
|
if path.startswith(STACK_MACHINERY_PREFIXES):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
YES_REMOVED_MESSAGE = (
|
YES_REMOVED_MESSAGE = (
|
||||||
"--yes no longer exists. The Mass-Update Gate is cleared with `--confirm <token>`, and the "
|
"--yes no longer exists. The Mass-Update Gate is cleared with `--confirm <token>`, and the "
|
||||||
"token comes from the gate's own refusal output - run this command without it first to see "
|
"token comes from the gate's own refusal output - run this command without it first to see "
|
||||||
@@ -1138,3 +1178,5 @@ def publish_command(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
success(f"Published changes to {remote}/{branch}." if push else "Committed changes (not pushed).")
|
success(f"Published changes to {remote}/{branch}." if push else "Committed changes (not pushed).")
|
||||||
|
if touches_stack_machinery(changed_files):
|
||||||
|
typer.echo(STACK_MACHINERY_NOTE)
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ from chemenu import config
|
|||||||
from chemenu.commands._util import rel_path, success
|
from chemenu.commands._util import rel_path, success
|
||||||
from chemenu.lint_core import (
|
from chemenu.lint_core import (
|
||||||
HARD_ERROR_KEYS,
|
HARD_ERROR_KEYS,
|
||||||
|
MIGRATION_GATED_KEYS,
|
||||||
MOST_LINKED_COUNT,
|
MOST_LINKED_COUNT,
|
||||||
QUOTE_LIMIT,
|
QUOTE_LIMIT,
|
||||||
count_quote_blocks,
|
count_quote_blocks,
|
||||||
default_report_path,
|
default_report_path,
|
||||||
|
hard_error_keys,
|
||||||
has_hard_errors,
|
has_hard_errors,
|
||||||
render_markdown,
|
render_markdown,
|
||||||
render_summary,
|
render_summary,
|
||||||
@@ -30,10 +32,12 @@ from chemenu.lint_core import (
|
|||||||
# so does every other name the tests and sibling commands already import.
|
# so does every other name the tests and sibling commands already import.
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HARD_ERROR_KEYS",
|
"HARD_ERROR_KEYS",
|
||||||
|
"MIGRATION_GATED_KEYS",
|
||||||
"MOST_LINKED_COUNT",
|
"MOST_LINKED_COUNT",
|
||||||
"QUOTE_LIMIT",
|
"QUOTE_LIMIT",
|
||||||
"count_quote_blocks",
|
"count_quote_blocks",
|
||||||
"default_report_path",
|
"default_report_path",
|
||||||
|
"hard_error_keys",
|
||||||
"has_hard_errors",
|
"has_hard_errors",
|
||||||
"render_markdown",
|
"render_markdown",
|
||||||
"render_summary",
|
"render_summary",
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ def status_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
pending = kb_state.chain(migrations, kb_version, stack)
|
pending = kb_state.chain(migrations, kb_version, stack.base)
|
||||||
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
||||||
divergent = kb_state.divergent_files()
|
divergent = kb_state.divergent_files()
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ def done_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
expected = kb_state.next_link(migrations, kb_version, stack)
|
expected = kb_state.next_link(migrations, kb_version, stack.base)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
fail(
|
fail(
|
||||||
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
||||||
@@ -297,7 +297,7 @@ def done_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
kb_state.write_kb_state(target, applied)
|
kb_state.write_kb_state(target, applied)
|
||||||
remaining = kb_state.chain(migrations, target, stack)
|
remaining = kb_state.chain(migrations, target, stack.base)
|
||||||
success(
|
success(
|
||||||
f"Content is now {target} ({expected.name}). "
|
f"Content is now {target} ({expected.name}). "
|
||||||
+ (
|
+ (
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ SKIP_COMMAND_PATHS = {
|
|||||||
("migrate", "list"),
|
("migrate", "list"),
|
||||||
("migrate", "status"),
|
("migrate", "status"),
|
||||||
("migrate", "verify"),
|
("migrate", "verify"),
|
||||||
|
# `upstream verify` only reads two git revisions and reports what changed -
|
||||||
|
# the same argument as `migrate verify`: a check that costs budget is one
|
||||||
|
# an agent starts skipping. `upstream merge` stays counted: it mutates the
|
||||||
|
# branch and can leave an open merge behind on refusal, so it belongs on
|
||||||
|
# the non-idempotent list (AGENTS.md's tool error contract) rather than
|
||||||
|
# the exempt one.
|
||||||
|
("upstream", "verify"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Commands exempt regardless of their first argument, because that argument is
|
# Commands exempt regardless of their first argument, because that argument is
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""`wikitool upstream` - take a stack update from a public upstream into a
|
||||||
|
private instance's `main` without letting the upstream's own content (a demo
|
||||||
|
corpus, a workshop run) ride along.
|
||||||
|
|
||||||
|
`git merge upstream/main` on its own treats a moved corpus dangerously
|
||||||
|
asymmetrically: a page the instance deleted and the upstream edited reports as
|
||||||
|
a conflict, a page the upstream *added* stages silently, and a page both sides
|
||||||
|
deleted is the only harmless case. `instructions/private-instance.md`'s prose
|
||||||
|
procedure closes that, by holding the merge open, forcing the content stages
|
||||||
|
(`ownership.CONTENT_STAGES`) back to the local side, and then restoring only
|
||||||
|
the paths `ownership.is_stack_owned` recognises as machinery. `upstream merge`
|
||||||
|
is that procedure in code, so the path set it acts on cannot drift from the
|
||||||
|
one `dist_cmd.py` ships - both read `chemenu.ownership` - and so a conflict in
|
||||||
|
the machinery layers, or a machinery file the upstream deleted, gets an
|
||||||
|
explained stop instead of a silently wrong commit.
|
||||||
|
|
||||||
|
`upstream verify` is the other half: given two revisions, did anything change
|
||||||
|
under a content stage except through a stack-owned path? It shares
|
||||||
|
`_content_leaks` with the postcheck `upstream merge` runs on itself, so a
|
||||||
|
hand-resolved merge or a future `dist upgrade` (Gitea #7) can be checked the
|
||||||
|
same way.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from chemenu import config, ownership
|
||||||
|
from chemenu.commands import git_publish
|
||||||
|
from chemenu.commands._util import console, fail, success
|
||||||
|
|
||||||
|
app = typer.Typer(help="Take a stack update from a public upstream, machinery only.")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args: list[str]):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rev_parse(rev: str) -> Optional[str]:
|
||||||
|
result = _run(["git", "rev-parse", "--verify", "-q", rev])
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_dir() -> Optional[Path]:
|
||||||
|
result = _run(["git", "rev-parse", "--git-dir"])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
path = Path(result.stdout.strip())
|
||||||
|
return path if path.is_absolute() else config.ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def _working_tree_dirty() -> bool:
|
||||||
|
result = _run(["git", "status", "--porcelain"])
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_in_progress() -> bool:
|
||||||
|
git_dir = _git_dir()
|
||||||
|
return git_dir is not None and (git_dir / "MERGE_HEAD").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_resolves(remote: str) -> bool:
|
||||||
|
return _run(["git", "remote", "get-url", remote]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ancestor(ancestor: str, of: str) -> bool:
|
||||||
|
return _run(["git", "merge-base", "--is-ancestor", ancestor, of]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_has_path(rev: str, path: str) -> bool:
|
||||||
|
return _run(["git", "rev-parse", "--verify", "-q", f"{rev}:{path}"]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_paths(rev: str) -> set[str]:
|
||||||
|
result = _run(["git", "ls-tree", "-r", "--name-only", "-z", rev])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return set()
|
||||||
|
return {p for p in result.stdout.split("\0") if p}
|
||||||
|
|
||||||
|
|
||||||
|
def _content_leaks(since: str, until: str) -> list[str]:
|
||||||
|
"""Paths under a content stage that changed between `since` and `until`
|
||||||
|
through something other than a stack-owned path. Shared by `upstream
|
||||||
|
merge`'s own postcheck and `upstream verify`, so the two cannot disagree
|
||||||
|
about what a clean update looks like."""
|
||||||
|
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(
|
||||||
|
f"`git diff {since} {until}` failed - is {since} a revision in this repository?\n"
|
||||||
|
f"{result.stderr}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
changed = [p for p in result.stdout.split("\0") if p]
|
||||||
|
return sorted(p for p in changed if not ownership.is_stack_owned(p))
|
||||||
|
|
||||||
|
|
||||||
|
def _stack_paths_changed(since: str, until: str) -> list[str]:
|
||||||
|
"""The subset of the same diff that *is* a stack-owned path - the paths
|
||||||
|
that legitimately moved, for the success message."""
|
||||||
|
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
|
||||||
|
changed = [p for p in result.stdout.split("\0") if p]
|
||||||
|
return sorted(p for p in changed if ownership.is_stack_owned(p))
|
||||||
|
|
||||||
|
|
||||||
|
# --- upstream merge ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_empty_dirs(stage: str) -> None:
|
||||||
|
"""Remove directories left empty under `stage` after tracked files were
|
||||||
|
deleted. git tracks no directories, so an emptied one is invisible to
|
||||||
|
`git status` and would otherwise linger in the working tree as litter -
|
||||||
|
an empty `kb/<area>/` that only ever existed in the upstream's corpus.
|
||||||
|
Never touches a directory that still holds anything, ignored files
|
||||||
|
included."""
|
||||||
|
stage_dir = config.ROOT / stage
|
||||||
|
if not stage_dir.is_dir():
|
||||||
|
return
|
||||||
|
for path in sorted(stage_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
||||||
|
if path.is_dir() and not any(path.iterdir()):
|
||||||
|
path.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_stage_to_local(stage: str, tracked_paths: set[str]) -> None:
|
||||||
|
"""Force one content stage back to the local (HEAD) side, whatever the
|
||||||
|
merge did to it.
|
||||||
|
|
||||||
|
Deletes **only what git tracks on either side** - never the stage
|
||||||
|
directory wholesale. That distinction is the whole point of this function:
|
||||||
|
`reports/` is gitignored except its contract (see .gitignore), so a
|
||||||
|
content stage's working tree legitimately holds local data that is not in
|
||||||
|
any tree and not recomputable - the telemetry traces `eval score` reads,
|
||||||
|
saved eval reports, past lint reports. A blanket `rm -rf` of the stage
|
||||||
|
takes all of it out as collateral for a merge that was never about it.
|
||||||
|
|
||||||
|
Handles a stage that exists only in MERGE_HEAD too (the upstream
|
||||||
|
introduced it): what the merge wrote is removed, and there is simply
|
||||||
|
nothing to check out from HEAD afterwards.
|
||||||
|
"""
|
||||||
|
prefix = f"{stage}/"
|
||||||
|
stage_paths = [p for p in tracked_paths if p.startswith(prefix)]
|
||||||
|
if not stage_paths:
|
||||||
|
return
|
||||||
|
|
||||||
|
_run(["git", "rm", "-rq", "--cached", "--ignore-unmatch", stage])
|
||||||
|
for relative in stage_paths:
|
||||||
|
target = config.ROOT / relative
|
||||||
|
if target.is_file() or target.is_symlink():
|
||||||
|
target.unlink()
|
||||||
|
_prune_empty_dirs(stage)
|
||||||
|
if _tree_has_path("HEAD", stage):
|
||||||
|
_run(["git", "checkout", "HEAD", "--", stage])
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_gate_warning() -> None:
|
||||||
|
if git_publish.read_allowed_push_urls() is not None:
|
||||||
|
return
|
||||||
|
console.print(
|
||||||
|
"[bold yellow]WARN[/bold yellow] No .wikitool-remotes.json in this checkout - the "
|
||||||
|
"Publish-Remote Gate is unarmed, so a future `publish` to the wrong remote would not "
|
||||||
|
"be caught. `upstream merge` never pushes and proceeds regardless, but a checkout that "
|
||||||
|
"takes stack updates from a public upstream should arm the gate before its next publish "
|
||||||
|
"- see instructions/private-instance.md step 4."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _precondition_failure(remote: str) -> Optional[str]:
|
||||||
|
if _working_tree_dirty():
|
||||||
|
return (
|
||||||
|
"Working tree is not clean (`git status --porcelain` printed something). "
|
||||||
|
"`upstream merge` refuses to start on a dirty tree so a refusal never has to "
|
||||||
|
"guess which changes were already there. Commit or stash first."
|
||||||
|
)
|
||||||
|
if _merge_in_progress():
|
||||||
|
return (
|
||||||
|
"A merge is already in progress (.git/MERGE_HEAD exists). Resolve or abort it "
|
||||||
|
"(`git merge --abort`) before running `upstream merge`."
|
||||||
|
)
|
||||||
|
if not _remote_resolves(remote):
|
||||||
|
return f"Remote '{remote}' does not resolve (`git remote get-url {remote}` failed)."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unresolved_conflict_message(unresolved: list[str], remote: str, branch: str) -> str:
|
||||||
|
listed = "\n".join(f" - {p}" for p in unresolved)
|
||||||
|
return (
|
||||||
|
f"A real conflict remains in the machinery layers after restoring the content stages "
|
||||||
|
f"and the stack-owned paths from {remote}/{branch}:\n{listed}\n\n"
|
||||||
|
"The merge is left open, uncommitted - nothing was written to the branch. Per "
|
||||||
|
"instructions/private-instance.md's decision points: this means the checkout changed "
|
||||||
|
"the stack locally, which private instances do not do. Take the upstream side for "
|
||||||
|
"these paths (`git checkout --theirs -- <path>` then `git add`) and re-file the local "
|
||||||
|
"change as an issue against the public repo, or resolve deliberately and "
|
||||||
|
"`git commit --no-edit` yourself. `git merge --abort` gives up the merge entirely."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _postcheck_failure_message(leaks: list[str], before: str) -> str:
|
||||||
|
listed = "\n".join(f" - {p}" for p in leaks)
|
||||||
|
return (
|
||||||
|
f"The merge commit exists (content stages are not what they were before this ran), "
|
||||||
|
f"but it changed content outside of a stack-owned path:\n{listed}\n\n"
|
||||||
|
f"This was NOT rolled back - the state belongs in front of you, not behind an automatic "
|
||||||
|
f"repair the command applies to itself. Compare against the pre-merge commit ({before}) "
|
||||||
|
"and decide by hand whether to revert the merge commit, cherry-pick around it, or fix "
|
||||||
|
"forward. This is a bug in `upstream merge` or in `ownership.is_stack_owned` if it "
|
||||||
|
"reproduces - please report it rather than working around it silently."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_success_message(
|
||||||
|
changed: list[str], deleted: list[str], remote: str, branch: str
|
||||||
|
) -> str:
|
||||||
|
"""What the merge actually did, measured against the pre-merge commit
|
||||||
|
rather than against what was restored.
|
||||||
|
|
||||||
|
`changed` is the real diff - restoring every stack-owned path from
|
||||||
|
MERGE_HEAD touches each of them whether or not the upstream moved any, so
|
||||||
|
reporting the restore list would claim seven updates for a merge that
|
||||||
|
changed one file, and a reader who checks would find the report wrong.
|
||||||
|
"""
|
||||||
|
deleted_set = set(deleted)
|
||||||
|
lines = [
|
||||||
|
f"Merged {remote}/{branch}. Content stages "
|
||||||
|
f"({', '.join(ownership.CONTENT_STAGES)}) are unchanged."
|
||||||
|
]
|
||||||
|
if changed:
|
||||||
|
lines.append(f"Stack paths changed ({len(changed)}):")
|
||||||
|
lines += [
|
||||||
|
f" - {p}" + (" (deleted, following the upstream)" if p in deleted_set else "")
|
||||||
|
for p in changed
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines.append("No stack-owned path changed.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("merge")
|
||||||
|
def merge_command(
|
||||||
|
remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"),
|
||||||
|
branch: str = typer.Option("main", "--branch", help="Branch to merge"),
|
||||||
|
no_fetch: bool = typer.Option(
|
||||||
|
False, "--no-fetch", help="Skip `git fetch <remote>` - use whatever is already fetched"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Merge `<remote>/<branch>` into the current branch, machinery only:
|
||||||
|
every path under a content stage (kb/, raw/, work/, reports/) is forced
|
||||||
|
back to the local side except a stack-owned path (`<stage>/CONTRACT.md`,
|
||||||
|
or anything ending `.template` under a content stage), which is taken
|
||||||
|
from the upstream - including a deletion, if the upstream removed one. A
|
||||||
|
real conflict elsewhere (tools/, types/, instructions/) leaves the merge
|
||||||
|
open and unresolved rather than guessing. Not idempotent: it can leave an
|
||||||
|
open merge behind on refusal. See instructions/private-instance.md."""
|
||||||
|
problem = _precondition_failure(remote)
|
||||||
|
if problem:
|
||||||
|
fail(problem)
|
||||||
|
return
|
||||||
|
|
||||||
|
_remote_gate_warning()
|
||||||
|
|
||||||
|
before = _rev_parse("HEAD")
|
||||||
|
if before is None:
|
||||||
|
fail("HEAD does not resolve - is this a git repository with at least one commit?")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not no_fetch:
|
||||||
|
fetch_result = _run(["git", "fetch", remote, branch])
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
fail(f"`git fetch {remote} {branch}` failed:\n{fetch_result.stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_ref = f"{remote}/{branch}"
|
||||||
|
if _rev_parse(remote_ref) is None:
|
||||||
|
fail(f"'{remote_ref}' does not resolve - fetch it first, or check --remote/--branch.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_ancestor(remote_ref, "HEAD"):
|
||||||
|
success(f"Already up to date with {remote_ref}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# The exit code is deliberately not the test - conflicts under the content
|
||||||
|
# stages are expected here and are exactly what the next steps undo. What
|
||||||
|
# *is* load-bearing is that a merge actually opened: without MERGE_HEAD,
|
||||||
|
# `_tree_paths("MERGE_HEAD")` is empty, and every stack-owned path in HEAD
|
||||||
|
# would then read as "the upstream deleted it" and be removed. A merge git
|
||||||
|
# refused to start (unrelated histories, an ignored file in the way) must
|
||||||
|
# therefore stop here, with the tree untouched.
|
||||||
|
merge_result = _run(["git", "merge", "--no-commit", "--no-ff", remote_ref])
|
||||||
|
if not _merge_in_progress():
|
||||||
|
fail(
|
||||||
|
f"`git merge --no-commit --no-ff {remote_ref}` did not open a merge, so there is "
|
||||||
|
f"nothing to scope - the working tree is unchanged:\n"
|
||||||
|
f"{merge_result.stdout}{merge_result.stderr}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
merge_head_paths = _tree_paths("MERGE_HEAD")
|
||||||
|
head_paths = _tree_paths("HEAD")
|
||||||
|
tracked_paths = merge_head_paths | head_paths
|
||||||
|
|
||||||
|
for stage in ownership.CONTENT_STAGES:
|
||||||
|
_restore_stage_to_local(stage, tracked_paths)
|
||||||
|
|
||||||
|
stack_paths = sorted(
|
||||||
|
p for p in (merge_head_paths | head_paths) if ownership.is_stack_owned(p)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the deletions are recorded: what was *restored* is every stack-owned
|
||||||
|
# path in MERGE_HEAD, which is not the same question as what changed - the
|
||||||
|
# success message asks git for that instead.
|
||||||
|
deleted: list[str] = []
|
||||||
|
for relative in stack_paths:
|
||||||
|
if relative in merge_head_paths:
|
||||||
|
checkout = _run(["git", "checkout", "MERGE_HEAD", "--", relative])
|
||||||
|
if checkout.returncode != 0:
|
||||||
|
fail(
|
||||||
|
f"`git checkout MERGE_HEAD -- {relative}` failed even though it is listed "
|
||||||
|
f"in MERGE_HEAD's own tree:\n{checkout.stderr}\nThe merge is left open."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
_run(["git", "rm", "-q", "--cached", "--ignore-unmatch", relative])
|
||||||
|
target = config.ROOT / relative
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
deleted.append(relative)
|
||||||
|
|
||||||
|
unresolved = [p for p in _run(["git", "diff", "--name-only", "--diff-filter=U"]).stdout.splitlines() if p]
|
||||||
|
if unresolved:
|
||||||
|
fail(_unresolved_conflict_message(unresolved, remote, branch))
|
||||||
|
return
|
||||||
|
|
||||||
|
commit_result = _run(["git", "commit", "--no-edit"])
|
||||||
|
if commit_result.returncode != 0:
|
||||||
|
fail(f"`git commit --no-edit` failed:\n{commit_result.stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
leaks = _content_leaks(before, "HEAD")
|
||||||
|
if leaks:
|
||||||
|
fail(_postcheck_failure_message(leaks, before))
|
||||||
|
return
|
||||||
|
|
||||||
|
success(
|
||||||
|
_merge_success_message(_stack_paths_changed(before, "HEAD"), deleted, remote, branch)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- upstream verify ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_failure_message(leaks: list[str], since: str, until: str) -> str:
|
||||||
|
listed = "\n".join(f" - {p}" for p in leaks)
|
||||||
|
return (
|
||||||
|
f"Content under a content stage (kb/, raw/, work/, reports/) changed between {since} "
|
||||||
|
f"and {until} through a path that is not stack-owned:\n{listed}\n\n"
|
||||||
|
"That is upstream content (or an equivalent local change) that reached this range "
|
||||||
|
"outside of a stack-owned path - inspect it before trusting this range as machinery-only."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_success_message(stack_moved: list[str], since: str, until: str) -> str:
|
||||||
|
if not stack_moved:
|
||||||
|
return f"No content changed between {since} and {until} under kb/, raw/, work/, reports/."
|
||||||
|
listed = "\n".join(f" - {p}" for p in stack_moved)
|
||||||
|
return (
|
||||||
|
f"Clean: only stack-owned paths changed under kb/, raw/, work/, reports/ between "
|
||||||
|
f"{since} and {until}:\n{listed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("verify")
|
||||||
|
def verify_command(
|
||||||
|
since: str = typer.Option(..., "--since", help="Git revision to compare from"),
|
||||||
|
until: str = typer.Option("HEAD", "--until", help="Git revision to compare to"),
|
||||||
|
):
|
||||||
|
"""Check that nothing under a content stage changed between --since and
|
||||||
|
--until except through a stack-owned path. Read-only, and exempt from the
|
||||||
|
Iteration Budget Gate - the same treatment `migrate verify` gets, for the
|
||||||
|
same reason: a check an agent has to ration is a check that gets skipped."""
|
||||||
|
leaks = _content_leaks(since, until)
|
||||||
|
if leaks:
|
||||||
|
fail(_verify_failure_message(leaks, since, until))
|
||||||
|
return
|
||||||
|
success(_verify_success_message(_stack_paths_changed(since, until), since, until))
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
"""`wikitool version` - report, bump, and check the stack's version.
|
"""`wikitool version` - report, bump, release, and check the stack's version.
|
||||||
|
|
||||||
Three jobs that all hang off one number (see `chemenu/version.py` for what
|
Four jobs that all hang off one number (see `chemenu/version.py` for what that
|
||||||
that number means):
|
number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||||
|
|
||||||
- `version show` answers "which stack is this instance running", offline, from
|
- `version show` answers "which stack is this instance running", offline, from
|
||||||
`VERSION` plus the release stamp `dist export` writes.
|
`VERSION` plus the release stamp `dist export` writes.
|
||||||
- `version bump` moves it, and writes the changelog *heading* that has to
|
- `version bump` raises or continues the one running candidate between two
|
||||||
accompany the move - the same structure-by-tool/prose-by-author split as
|
releases, and writes the changelog *heading* that has to accompany it - the
|
||||||
`new`. `docs verify` then holds the two together.
|
same structure-by-tool/prose-by-author split as `new`. `docs verify` then
|
||||||
|
holds the two together.
|
||||||
|
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
||||||
|
closes its changelog entry. It is the only thing that turns a candidate into
|
||||||
|
a number a release actually consumes.
|
||||||
- `version check` is the one command in `wikitool` that makes a network call.
|
- `version check` is the one command in `wikitool` that makes a network call.
|
||||||
It is deliberately its own command: nothing else reaches for it implicitly,
|
It is deliberately its own command: nothing else reaches for it implicitly,
|
||||||
it needs no key, it times out, and a feed that cannot be reached is reported
|
it needs no key, it times out, and a feed that cannot be reached is reported
|
||||||
@@ -188,34 +192,36 @@ def bump_command(
|
|||||||
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
||||||
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
||||||
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
||||||
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
|
title: str = typer.Option(..., "--title", help="One-line title for the new/updated CHANGES.md entry"),
|
||||||
breaking: Optional[str] = typer.Option(
|
breaking: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--breaking",
|
"--breaking",
|
||||||
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
|
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",
|
||||||
),
|
),
|
||||||
no_migration: Optional[str] = typer.Option(
|
no_migration: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--no-migration",
|
"--no-migration",
|
||||||
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
|
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
|
||||||
),
|
),
|
||||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||||
):
|
):
|
||||||
"""Raise the stack version and open its `CHANGES.md` entry.
|
"""Raise or continue the running candidate, and open or update its
|
||||||
|
`CHANGES.md` entry.
|
||||||
|
|
||||||
Writes `VERSION` and inserts the entry's heading, date and author - the
|
Between two releases the stack carries **one** candidate, not a fresh
|
||||||
entry's body stays the author's to write, the same way `new` produces
|
number per bump: `--patch/--minor/--major` is max-wins escalation against
|
||||||
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
the last release, never a step back down, and the candidate's bump count
|
||||||
the two agree, so a bump with no entry cannot reach a release.
|
(`-beta.N`) advances either way. See
|
||||||
|
`instructions/dev/version-parts.md` for the full model, and
|
||||||
|
`version release` for what fixes a candidate into a release.
|
||||||
|
|
||||||
A bump that crosses the compatibility boundary - one whose new version is
|
A bump whose escalation first crosses the compatibility boundary - the new
|
||||||
not a drop-in replacement, whether or not any content moves - requires
|
version is not a drop-in replacement, whether or not any content moves -
|
||||||
`--breaking "<what stops working>"`, and on top of that either a migration
|
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||||
document for the new version or `--no-migration "<reason>"`. An instance
|
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||||
learning that it must migrate, with nothing telling it what broke or how to
|
lines are written into the entry once and then persist across every later
|
||||||
cross, is the gap these close. Which part to pass stays a judgment call
|
bump at the same stage: a follow-up bump need not repeat them, and passing
|
||||||
this command does not make - it enforces only that a crossing says what it
|
either on a bump that crosses nothing at all is refused."""
|
||||||
costs."""
|
|
||||||
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
||||||
if len(selected) != 1:
|
if len(selected) != 1:
|
||||||
fail("Pass exactly one of --major / --minor / --patch")
|
fail("Pass exactly one of --major / --minor / --patch")
|
||||||
@@ -226,7 +232,6 @@ def bump_command(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
new_version = current.bumped(selected[0])
|
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
fail(str(exc))
|
fail(str(exc))
|
||||||
return
|
return
|
||||||
@@ -236,19 +241,29 @@ def bump_command(
|
|||||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
||||||
return
|
return
|
||||||
text = changes.read_text(encoding="utf-8")
|
text = changes.read_text(encoding="utf-8")
|
||||||
existing = version_mod.top_changes_version(text)
|
|
||||||
if existing is not None and existing >= new_version:
|
top_entry = version_mod.top_changes_version(text)
|
||||||
|
if top_entry is not None and top_entry != current:
|
||||||
fail(
|
fail(
|
||||||
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
|
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||||
f"than {new_version} - bump past it, or fix the changelog"
|
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
|
||||||
|
"Fix whichever is wrong."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
last_release = version_mod.last_release(text)
|
||||||
|
new_version = version_mod.escalate(last_release, current, selected[0])
|
||||||
|
|
||||||
author = config.default_author() or "unknown"
|
author = config.default_author() or "unknown"
|
||||||
crossing = new_version.compat_key != current.compat_key
|
crossing = last_release is not None and new_version.compat_key != last_release.compat_key
|
||||||
|
was_already_crossing = (
|
||||||
|
last_release is not None
|
||||||
|
and current.is_prerelease
|
||||||
|
and current.compat_key != last_release.compat_key
|
||||||
|
)
|
||||||
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
||||||
|
|
||||||
if crossing and not breaking:
|
if crossing and not was_already_crossing and not breaking:
|
||||||
fail(
|
fail(
|
||||||
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
||||||
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
||||||
@@ -265,14 +280,14 @@ def bump_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if crossing and not no_migration:
|
if crossing and not was_already_crossing and not no_migration:
|
||||||
from chemenu import kb_state
|
from chemenu import kb_state
|
||||||
|
|
||||||
if not any(m.target == new_version for m in kb_state.load_migrations()):
|
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
|
||||||
fail(
|
fail(
|
||||||
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
||||||
f"instance must migrate - but no migration document targets {new_version}.\n"
|
f"instance must migrate - but no migration document targets {new_version.base}.\n"
|
||||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
|
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
|
||||||
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
||||||
f're-run with --no-migration "<reason>".'
|
f're-run with --no-migration "<reason>".'
|
||||||
)
|
)
|
||||||
@@ -298,6 +313,76 @@ def bump_command(
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
success(
|
success(
|
||||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
|
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||||
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
|
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
|
||||||
|
f"`version release` once the candidate is ready to ship."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("release")
|
||||||
|
def release_command(
|
||||||
|
title: Optional[str] = typer.Option(
|
||||||
|
None, "--title", help="Replace the entry's heading title (default: the last bump's)"
|
||||||
|
),
|
||||||
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||||
|
):
|
||||||
|
"""Fix the running candidate: strip its `-beta.N` suffix and close its
|
||||||
|
`CHANGES.md` entry.
|
||||||
|
|
||||||
|
Ends the pre-release phase this checkout has been in since its last
|
||||||
|
`version bump` - the candidate's base becomes the release. Without
|
||||||
|
`--title` the heading keeps whichever bump last set it; with it, the
|
||||||
|
heading gets a summarising title instead, which is the normal case for a
|
||||||
|
candidate that collected several bump titles along the way. The
|
||||||
|
machine-managed list of those titles is left in the entry as the record of
|
||||||
|
what happened, not replaced.
|
||||||
|
|
||||||
|
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||||
|
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||||
|
Refuses when `VERSION` is already a release: there is no running candidate
|
||||||
|
to fix."""
|
||||||
|
try:
|
||||||
|
current = version_mod.read_version()
|
||||||
|
except VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current.is_prerelease:
|
||||||
|
fail(
|
||||||
|
f"{version_mod.VERSION_FILENAME} is already {current}, a release - there is no running "
|
||||||
|
"candidate to fix. `version release` only ends a pre-release phase that `version bump` "
|
||||||
|
"started."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
changes = version_mod.changes_file()
|
||||||
|
if not changes.is_file():
|
||||||
|
fail(f"{version_mod.CHANGES_FILENAME} is missing - the candidate has nowhere to be fixed")
|
||||||
|
return
|
||||||
|
text = changes.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
top_entry = version_mod.top_changes_version(text)
|
||||||
|
if top_entry != current:
|
||||||
|
fail(
|
||||||
|
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||||
|
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a release. "
|
||||||
|
"Fix whichever is wrong."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_version = current.base
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
||||||
|
return
|
||||||
|
|
||||||
|
version_mod.write_version(new_version)
|
||||||
|
changes.write_text(
|
||||||
|
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
success(
|
||||||
|
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
|
||||||
|
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||||
|
"is what release.yml reacts to."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ ADVISORY_KEYS = (
|
|||||||
"unmarked_provenance",
|
"unmarked_provenance",
|
||||||
"missing_from_index",
|
"missing_from_index",
|
||||||
"title_mismatches",
|
"title_mismatches",
|
||||||
|
"redundant_see_also",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ def check_gate_not_self_opened(records: list[dict]) -> Rule:
|
|||||||
"ts": record["ts"], "call": _signature(attrs),
|
"ts": record["ts"], "call": _signature(attrs),
|
||||||
"flag": arg, "reason": "force flag, never permitted",
|
"flag": arg, "reason": "force flag, never permitted",
|
||||||
})
|
})
|
||||||
elif arg in REMOVED_FLAGS:
|
elif arg in REMOVED_FLAGS and attrs.get("command") == REMOVED_FLAGS[arg]:
|
||||||
rule.findings.append({
|
rule.findings.append({
|
||||||
"ts": record["ts"], "call": _signature(attrs), "flag": arg,
|
"ts": record["ts"], "call": _signature(attrs), "flag": arg,
|
||||||
"reason": f"{arg} no longer exists on {REMOVED_FLAGS[arg]} - a stale skill "
|
"reason": f"{arg} no longer exists on {REMOVED_FLAGS[arg]} - a stale skill "
|
||||||
|
|||||||
@@ -183,6 +183,33 @@ def authorised_labels(source: str, destination: str, kb_dir: Path | None = None)
|
|||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
LABELLED_EDGE_FIELD = "related"
|
||||||
|
|
||||||
|
|
||||||
|
def collections_that_can_carry_labelled_edges() -> set[str]:
|
||||||
|
"""Collection names whose offered page types actually have somewhere to put
|
||||||
|
a labelled edge.
|
||||||
|
|
||||||
|
Derived from `page_ref_fields:`, the same way `stack_required_collections()`
|
||||||
|
is derived from `base_dir:`: a type that does not offer `related:` cannot
|
||||||
|
carry a label, no matter what its collection's contract authorises.
|
||||||
|
"""
|
||||||
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
|
names: set[str] = set()
|
||||||
|
for type_path, _frontmatter in resolver.list_type_specs():
|
||||||
|
try:
|
||||||
|
if resolver.get_root(type_path) != "kb":
|
||||||
|
continue
|
||||||
|
base_dir = resolver.get_base_dir(type_path)
|
||||||
|
fields = resolver.get_page_ref_fields(type_path)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
continue
|
||||||
|
if base_dir and LABELLED_EDGE_FIELD in fields:
|
||||||
|
names.add(str(base_dir).strip("/"))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||||
"""What each `COLLECTION.md` fails to declare about itself.
|
"""What each `COLLECTION.md` fails to declare about itself.
|
||||||
|
|
||||||
@@ -200,6 +227,7 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
|||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
|
|
||||||
required = stack_required_collections()
|
required = stack_required_collections()
|
||||||
|
can_label = collections_that_can_carry_labelled_edges()
|
||||||
present = {path.name for path in iter_kb_collections(root)}
|
present = {path.name for path in iter_kb_collections(root)}
|
||||||
for name in required:
|
for name in required:
|
||||||
if name not in present:
|
if name not in present:
|
||||||
@@ -244,6 +272,20 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
|||||||
)
|
)
|
||||||
+ f" - it must be {str(expected).lower()}"
|
+ f" - it must be {str(expected).lower()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# An `outbound:` block on a collection whose types offer no `related:`
|
||||||
|
# authorises labels that no page there can write. That is not a harmless
|
||||||
|
# extra: it reads as a licence, so the label gets written into the prose
|
||||||
|
# by hand instead - an identifier back in free text, which is the exact
|
||||||
|
# thing the labelled-edge model exists to end. The two halves have to
|
||||||
|
# move together, so the check names both directions of the fix.
|
||||||
|
if declared.get(OUTBOUND_FIELD) and collection.name not in can_label:
|
||||||
|
issues.append(
|
||||||
|
f"{relative}: `{OUTBOUND_FIELD}:` authorises labels, but no page type writing "
|
||||||
|
f"into kb/{collection.name}/ offers a `{LABELLED_EDGE_FIELD}:` field - so no "
|
||||||
|
f"page here can carry a labelled edge. Either drop the block, or give the "
|
||||||
|
f"type-spec a `{LABELLED_EDGE_FIELD}:` in its `page_ref_fields:` and schema"
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+78
-28
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
|
|||||||
None is a real state, not an error: an instance created before the KB
|
None is a real state, not an error: an instance created before the KB
|
||||||
version existed has content of unknown vintage, and guessing would be
|
version existed has content of unknown vintage, and guessing would be
|
||||||
worse than asking (`migrate baseline`).
|
worse than asking (`migrate baseline`).
|
||||||
|
|
||||||
|
Refuses a pre-release (`-beta.N`): a content *shape* has no beta channel,
|
||||||
|
only the machinery does, so a `kb_version` naming one means something
|
||||||
|
wrote a stack version into this field by hand or by mistake.
|
||||||
"""
|
"""
|
||||||
state = read_kb_state()
|
state = read_kb_state()
|
||||||
if state is None:
|
if state is None:
|
||||||
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
|
|||||||
raw = state.get("kb_version")
|
raw = state.get("kb_version")
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
return Version.parse(str(raw))
|
version = Version.parse(str(raw))
|
||||||
|
if version.is_prerelease:
|
||||||
|
raise VersionError(
|
||||||
|
f"{KB_STATE_FILENAME} names a pre-release kb_version ({version}) - content has no "
|
||||||
|
"beta channel, only the stack version does"
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
def read_kb_state() -> Optional[dict]:
|
def read_kb_state() -> Optional[dict]:
|
||||||
@@ -119,21 +129,27 @@ def migrations_dir() -> Path:
|
|||||||
return config.INSTRUCTIONS_DIR / MIGRATIONS_SUBDIR
|
return config.INSTRUCTIONS_DIR / MIGRATIONS_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
def load_migrations() -> list[Migration]:
|
def load_migrations(directory: Optional[Path] = None) -> list[Migration]:
|
||||||
"""Every migration document, sorted by target version.
|
"""Every migration document under `directory`, sorted by target version.
|
||||||
|
|
||||||
A malformed one is skipped rather than fatal here - `instructions verify`
|
`directory` defaults to this instance's own `instructions/migrations/`.
|
||||||
is what reports it, and `migrate status` staying usable while one document
|
`dist upgrade` (Gitea #7) passes the *new* tree's migrations directory
|
||||||
is broken is worth more than a second error path.
|
instead: the migrations owed after an upgrade are documented in the
|
||||||
|
machinery being installed, not in the one still on disk - an old instance
|
||||||
|
cannot know a new version's migration chain by reading its own tree.
|
||||||
|
|
||||||
|
A malformed document is skipped rather than fatal here - `instructions
|
||||||
|
verify` is what reports it, and `migrate status` staying usable while one
|
||||||
|
document is broken is worth more than a second error path.
|
||||||
"""
|
"""
|
||||||
from chemenu.frontmatter_io import read_page
|
from chemenu.frontmatter_io import read_page
|
||||||
|
|
||||||
directory = migrations_dir()
|
base = directory if directory is not None else migrations_dir()
|
||||||
if not directory.is_dir():
|
if not base.is_dir():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
migrations: list[Migration] = []
|
migrations: list[Migration] = []
|
||||||
for path in sorted(directory.glob("*.md")):
|
for path in sorted(base.glob("*.md")):
|
||||||
try:
|
try:
|
||||||
frontmatter, _ = read_page(path)
|
frontmatter, _ = read_page(path)
|
||||||
except Exception: # noqa: BLE001 - a broken document is verify's finding, not ours
|
except Exception: # noqa: BLE001 - a broken document is verify's finding, not ours
|
||||||
@@ -171,6 +187,13 @@ def chain(
|
|||||||
Targets above the installed machinery are excluded: the instance has no code
|
Targets above the installed machinery are excluded: the instance has no code
|
||||||
for them yet.
|
for them yet.
|
||||||
|
|
||||||
|
`stack_version` must be release-shaped (no `-beta.N`) - pass `.base` when
|
||||||
|
the installed machinery is a running candidate. A migration document
|
||||||
|
targets a release (`migrates_to: 4.4.0`), and a candidate's own version
|
||||||
|
sorts *before* that release (`4.4.0-beta.1 < 4.4.0`), so comparing against
|
||||||
|
the raw candidate would drop its own target out of the interval right
|
||||||
|
when the machinery that owes it is installed.
|
||||||
|
|
||||||
`offered` migrations are deliberately absent. They are not links in the
|
`offered` migrations are deliberately absent. They are not links in the
|
||||||
version chain: declining one leaves the content in a shape the machinery
|
version chain: declining one leaves the content in a shape the machinery
|
||||||
still accepts, so counting it as owed would make `kb_version` unreachable
|
still accepts, so counting it as owed would make `kb_version` unreachable
|
||||||
@@ -215,25 +238,60 @@ def next_link(
|
|||||||
|
|
||||||
# --- what this instance changed about what it was given --------------------
|
# --- what this instance changed about what it was given --------------------
|
||||||
|
|
||||||
|
# What `compare_against_stamp` answers for one path: present and matching its
|
||||||
|
# recorded digest, present but not matching, or gone entirely. `dist upgrade`
|
||||||
|
# needs the three-way answer to tell a locally deleted file from a locally
|
||||||
|
# edited one; `divergent_files` (below) only ever needed the yes/no of
|
||||||
|
# "does this count as diverged", which both `MODIFIED` and `DELETED` answer
|
||||||
|
# the same way.
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
MODIFIED = "modified"
|
||||||
|
DELETED = "deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_against_stamp(stamp_files: dict, root: Optional[Path] = None) -> dict[str, str]:
|
||||||
|
"""Classify every path in `stamp_files` (relative -> recorded sha256, the
|
||||||
|
shape of a release stamp's own `files` block) against what is actually on
|
||||||
|
disk under `root` - `UNCHANGED`, `MODIFIED`, or `DELETED`.
|
||||||
|
|
||||||
|
`root` defaults to `config.ROOT`. The general form `divergent_files` is
|
||||||
|
built on: that function only ever asks the question against this
|
||||||
|
instance's own tree, but `dist upgrade` (Gitea #7) asks it against an
|
||||||
|
already-installed tree while planning what to write, and a *second* time
|
||||||
|
against the tree it just wrote, before recording the new stamp - two trees
|
||||||
|
neither of which is necessarily `config.ROOT`.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
base = root if root is not None else config.ROOT
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for relative, digest in sorted(stamp_files.items()):
|
||||||
|
path = base / relative
|
||||||
|
if not path.is_file():
|
||||||
|
result[relative] = DELETED
|
||||||
|
continue
|
||||||
|
current = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
result[relative] = UNCHANGED if current == digest else MODIFIED
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def divergent_files() -> Optional[list[str]]:
|
def divergent_files() -> Optional[list[str]]:
|
||||||
"""Files whose content no longer matches the release this instance installed.
|
"""Files whose content no longer matches the release this instance installed.
|
||||||
|
|
||||||
Reads the per-file sha256 in `.wikitool-release.json`, which `dist export`
|
Reads the per-file sha256 in `.wikitool-release.json`, which `dist export`
|
||||||
has been writing since the stamp existed and which nothing has read until
|
has been writing since the stamp existed. Its own docstring says why it is
|
||||||
now. Its own docstring says why it is there: it is the only way a later
|
there: it is the only way a later upgrade can tell a file the instance
|
||||||
upgrade can tell a file the instance *edited* from one it merely *received*.
|
*edited* from one it merely *received* - `dist upgrade` (Gitea #7) is that
|
||||||
|
later upgrade, built on the general `compare_against_stamp` above.
|
||||||
|
|
||||||
That distinction is what makes an `offered` migration actionable. The stack
|
That distinction is also what makes an `offered` migration actionable. The
|
||||||
proposing a better `entity` template needs to know whether it may be copied
|
stack proposing a better `entity` template needs to know whether it may be
|
||||||
over or whether the instance has its own version that a person has to
|
copied over or whether the instance has its own version that a person has
|
||||||
reconcile - and only the recorded hash can answer that.
|
to reconcile - and only the recorded hash can answer that.
|
||||||
|
|
||||||
Returns None when the question is unanswerable (a development tree, which
|
Returns None when the question is unanswerable (a development tree, which
|
||||||
carries no stamp), which is different from `[]` (nothing diverged).
|
carries no stamp), which is different from `[]` (nothing diverged).
|
||||||
"""
|
"""
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -246,13 +304,5 @@ def divergent_files() -> Optional[list[str]]:
|
|||||||
if not isinstance(recorded, dict):
|
if not isinstance(recorded, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
divergent: list[str] = []
|
statuses = compare_against_stamp(recorded)
|
||||||
for relative, digest in sorted(recorded.items()):
|
return [relative for relative, status in statuses.items() if status != UNCHANGED]
|
||||||
path = config.ROOT / relative
|
|
||||||
if not path.is_file():
|
|
||||||
divergent.append(relative)
|
|
||||||
continue
|
|
||||||
current = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
if current != digest:
|
|
||||||
divergent.append(relative)
|
|
||||||
return divergent
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ from typing import Any, Iterable, Optional
|
|||||||
# so `lint` cannot be satisfied by declaring the placeholder legal.
|
# so `lint` cannot be satisfied by declaring the placeholder legal.
|
||||||
UNLABELLED = None
|
UNLABELLED = None
|
||||||
|
|
||||||
|
# The one catalogue label this tool knows by name. Everything else about the
|
||||||
|
# vocabulary lives in `instructions/link-taxonomy.md` and each collection's
|
||||||
|
# `outbound:` block, on purpose - an instance may authorise any label it likes
|
||||||
|
# and the tool never has an opinion about which. `see-also` is the exception
|
||||||
|
# because it is the catalogue's declared last resort: it asserts only that
|
||||||
|
# nothing better fit, which is what lets `lint` judge it as *weaker than*
|
||||||
|
# another edge on the same pair rather than merely different. No behaviour
|
||||||
|
# depends on the string beyond that comparison, and an instance that dropped
|
||||||
|
# `see-also` from every contract would simply never see the finding.
|
||||||
|
SEE_ALSO = "see-also"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Edge:
|
class Edge:
|
||||||
|
|||||||
+101
-11
@@ -27,6 +27,7 @@ from chemenu.provenance import legacy_source_pages as find_legacy_source_pages
|
|||||||
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
|
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
|
||||||
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
|
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
|
||||||
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
|
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
|
||||||
|
from chemenu.version import Version
|
||||||
from chemenu.kb_scan import (
|
from chemenu.kb_scan import (
|
||||||
GENERATED_INDEX,
|
GENERATED_INDEX,
|
||||||
WIKILINK_RE,
|
WIKILINK_RE,
|
||||||
@@ -164,9 +165,15 @@ def run_lint(kb_dir: Path) -> dict:
|
|||||||
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
||||||
# to pass every check. Which fields hold page titles is declared by each
|
# to pass every check. Which fields hold page titles is declared by each
|
||||||
# type-spec's `page_ref_fields:`, not hardcoded here.
|
# type-spec's `page_ref_fields:`, not hardcoded here.
|
||||||
|
# Resolved against the directory `run_lint()` was handed, not against
|
||||||
|
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
|
||||||
|
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
|
||||||
|
# `ValueError` here and read as "no collection", which made the label
|
||||||
|
# authorisation below skip the edge in silence rather than judge it
|
||||||
|
# (Gitea #44).
|
||||||
def _collection_of(page):
|
def _collection_of(page):
|
||||||
try:
|
try:
|
||||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
return page.path.relative_to(kb_dir).parts[0]
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -174,6 +181,26 @@ def run_lint(kb_dir: Path) -> dict:
|
|||||||
malformed_edges: list[dict] = []
|
malformed_edges: list[dict] = []
|
||||||
unlabelled_edges: list[dict] = []
|
unlabelled_edges: list[dict] = []
|
||||||
unauthorised_labels: list[dict] = []
|
unauthorised_labels: list[dict] = []
|
||||||
|
redundant_see_also: list[dict] = []
|
||||||
|
|
||||||
|
# Every specific thing any page asserts about any pair, collected before the
|
||||||
|
# loop below because the reverse direction of an edge is not known while
|
||||||
|
# standing on the page that carries it.
|
||||||
|
#
|
||||||
|
# What it is for: `see-also` is the catalogue's declared last resort - it
|
||||||
|
# asserts that nothing better fit. When the *other* page already says
|
||||||
|
# something specific about the same pair (`Wine GE depends-on Wine` opposite
|
||||||
|
# `Wine see-also Wine GE`), the weak edge adds nothing a reader did not
|
||||||
|
# have: direction is authored but the inbound view is rendered, so the
|
||||||
|
# labelled edge already shows on both pages. Measured once on this corpus,
|
||||||
|
# that was 57 of 180 `see-also` edges - the largest single class, and none
|
||||||
|
# of it a vocabulary gap.
|
||||||
|
typed_edges: dict[tuple[str, str], str] = {}
|
||||||
|
for source_title, source_page in pages.items():
|
||||||
|
for edge in links.edges(source_page.frontmatter, "related"):
|
||||||
|
if edge.is_labelled and edge.label != links.SEE_ALSO:
|
||||||
|
typed_edges[(source_title, edge.target)] = edge.label
|
||||||
|
|
||||||
for title, page in sorted(pages.items()):
|
for title, page in sorted(pages.items()):
|
||||||
type_path = page.frontmatter.get("type")
|
type_path = page.frontmatter.get("type")
|
||||||
if not type_path:
|
if not type_path:
|
||||||
@@ -204,13 +231,25 @@ def run_lint(kb_dir: Path) -> dict:
|
|||||||
if not edge.is_labelled:
|
if not edge.is_labelled:
|
||||||
unlabelled_edges.append({"page": title, "target": edge.target})
|
unlabelled_edges.append({"page": title, "target": edge.target})
|
||||||
continue
|
continue
|
||||||
|
if edge.label == links.SEE_ALSO:
|
||||||
|
reverse_label = typed_edges.get((edge.target, title))
|
||||||
|
if reverse_label is not None:
|
||||||
|
redundant_see_also.append(
|
||||||
|
{
|
||||||
|
"page": title,
|
||||||
|
"target": edge.target,
|
||||||
|
"reverse_label": reverse_label,
|
||||||
|
}
|
||||||
|
)
|
||||||
target_page = pages.get(edge.target)
|
target_page = pages.get(edge.target)
|
||||||
if source_collection is None or target_page is None:
|
if source_collection is None or target_page is None:
|
||||||
continue
|
continue
|
||||||
destination = _collection_of(target_page)
|
destination = _collection_of(target_page)
|
||||||
if destination is None:
|
if destination is None:
|
||||||
continue
|
continue
|
||||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
allowed = kb_collections.authorised_labels(
|
||||||
|
source_collection, destination, kb_dir
|
||||||
|
)
|
||||||
if edge.label not in allowed:
|
if edge.label not in allowed:
|
||||||
unauthorised_labels.append(
|
unauthorised_labels.append(
|
||||||
{
|
{
|
||||||
@@ -292,6 +331,7 @@ def run_lint(kb_dir: Path) -> dict:
|
|||||||
"malformed_edges": malformed_edges,
|
"malformed_edges": malformed_edges,
|
||||||
"unlabelled_edges": unlabelled_edges,
|
"unlabelled_edges": unlabelled_edges,
|
||||||
"unauthorised_labels": unauthorised_labels,
|
"unauthorised_labels": unauthorised_labels,
|
||||||
|
"redundant_see_also": redundant_see_also,
|
||||||
"unbalanced_markers": unbalanced_marker_findings,
|
"unbalanced_markers": unbalanced_marker_findings,
|
||||||
"quote_limit_violations": quote_limit_violations,
|
"quote_limit_violations": quote_limit_violations,
|
||||||
"invalid_type_paths": invalid_type_paths,
|
"invalid_type_paths": invalid_type_paths,
|
||||||
@@ -394,6 +434,11 @@ def render_markdown(report: dict) -> str:
|
|||||||
report.get("unauthorised_labels", []),
|
report.get("unauthorised_labels", []),
|
||||||
lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])",
|
lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])",
|
||||||
)
|
)
|
||||||
|
_section(
|
||||||
|
lines, "Redundant see-also (the other page already says something specific)",
|
||||||
|
report.get("redundant_see_also", []),
|
||||||
|
lambda i: f"[[{i['page']}]] `see-also` -> [[{i['target']}]], but [[{i['target']}]] already asserts `{i['reverse_label']}` back - drop the weaker edge, the inbound view renders the other one here",
|
||||||
|
)
|
||||||
_section(
|
_section(
|
||||||
lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"],
|
lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"],
|
||||||
lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page",
|
lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page",
|
||||||
@@ -476,14 +521,13 @@ def default_report_path(report: dict) -> Path:
|
|||||||
# Findings that make a tree structurally wrong rather than merely untidy.
|
# Findings that make a tree structurally wrong rather than merely untidy.
|
||||||
# `orphan_pages` is deliberately absent: many pages are validly reachable
|
# `orphan_pages` is deliberately absent: many pages are validly reachable
|
||||||
# through the index or navigation only. `quote_limit_violations` is advisory
|
# through the index or navigation only. `quote_limit_violations` is advisory
|
||||||
# too - it flags a habit, not a broken tree.
|
# too - it flags a habit, not a broken tree. `redundant_see_also` joins them for
|
||||||
#
|
# both of those reasons at once: a weak edge beside a specific one is redundant
|
||||||
# `unlabelled_edges` and `unauthorised_labels` are advisory **for now**, and
|
# rather than wrong, and the check arrived long after the corpora it judges, so
|
||||||
# that is a dated decision rather than a judgment about severity: they describe
|
# promoting it would turn every existing instance red on the upgrade that
|
||||||
# exactly the state a corpus is in between the 4.0.0 machinery landing and the
|
# shipped it. Unlike `unlabelled_edges` it is not migration-gated either - there
|
||||||
# migration reaching each page, which is the window `.wikitool-kb.json` exists
|
# is no version at which the redundancy becomes an error, only a sweep someone
|
||||||
# to represent. They become hard errors once the migration is recorded - the
|
# does or does not get to.
|
||||||
# same path `legacy_citation_markers` took.
|
|
||||||
#
|
#
|
||||||
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
||||||
# describes an unconverted page, only a broken one.
|
# describes an unconverted page, only a broken one.
|
||||||
@@ -505,11 +549,57 @@ HARD_ERROR_KEYS = (
|
|||||||
"dangling_frontmatter_refs",
|
"dangling_frontmatter_refs",
|
||||||
"malformed_edges",
|
"malformed_edges",
|
||||||
"unbalanced_markers",
|
"unbalanced_markers",
|
||||||
|
"unlabelled_edges",
|
||||||
|
"unauthorised_labels",
|
||||||
"invalid_type_paths",
|
"invalid_type_paths",
|
||||||
"type_resolution_errors",
|
"type_resolution_errors",
|
||||||
"schema_validation_errors",
|
"schema_validation_errors",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Findings that only become hard once the corpus has reached a given shape.
|
||||||
|
#
|
||||||
|
# `unlabelled_edges` and `unauthorised_labels` describe exactly the state a
|
||||||
|
# corpus is in between the 4.0.0 machinery landing and the migration reaching
|
||||||
|
# each page - the window `.wikitool-kb.json` exists to represent. Failing on
|
||||||
|
# them during that window would refuse the very corpus that
|
||||||
|
# `instructions/migrations/4.0.0-link-taxonomy.md` tells an instance to publish
|
||||||
|
# unit by unit. So the promotion is tied to `kb_version` rather than to a
|
||||||
|
# release date: below 4.0.0 they are advisory, at or above it an unlabelled
|
||||||
|
# edge is no longer a page awaiting conversion but an edge whose author did not
|
||||||
|
# say what it asserts.
|
||||||
|
#
|
||||||
|
# Gated rather than simply promoted, which is where this departs from
|
||||||
|
# `legacy_citation_markers`: that one was flipped in a later version and any
|
||||||
|
# instance still owing the citation migration had to live with a red lint. The
|
||||||
|
# ledger can answer the question now, so it does.
|
||||||
|
MIGRATION_GATED_KEYS: dict[str, Version] = {
|
||||||
|
"unlabelled_edges": Version(4, 0, 0),
|
||||||
|
"unauthorised_labels": Version(4, 0, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ALWAYS_HARD = Version(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def hard_error_keys(kb_version: Version | None = None) -> tuple[str, ...]:
|
||||||
|
"""`HARD_ERROR_KEYS` minus the findings this corpus has not grown into yet.
|
||||||
|
|
||||||
|
`kb_version` defaults to what `.wikitool-kb.json` records. A tree without
|
||||||
|
one - a fresh instance, which starts at the current shape rather than
|
||||||
|
migrating into it - keeps every key: there is no outstanding migration for
|
||||||
|
a gated finding to be the noise of.
|
||||||
|
"""
|
||||||
|
from chemenu import kb_state
|
||||||
|
|
||||||
|
if kb_version is None:
|
||||||
|
kb_version = kb_state.read_kb_version()
|
||||||
|
if kb_version is None:
|
||||||
|
return HARD_ERROR_KEYS
|
||||||
|
return tuple(
|
||||||
|
key
|
||||||
|
for key in HARD_ERROR_KEYS
|
||||||
|
if kb_version >= MIGRATION_GATED_KEYS.get(key, _ALWAYS_HARD)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_hard_errors(report: dict) -> bool:
|
def has_hard_errors(report: dict) -> bool:
|
||||||
return any(report.get(key) for key in HARD_ERROR_KEYS)
|
return any(report.get(key) for key in hard_error_keys())
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""The ownership boundary for a path under a content stage: does it belong to
|
||||||
|
the *stack* (ships with every distribution, wins over local content when a
|
||||||
|
private instance merges from a public upstream) or to the *instance* (never
|
||||||
|
ships filled, wins over the upstream's version)?
|
||||||
|
|
||||||
|
One predicate, so `dist_cmd.py` (export) and `upstream_cmd.py` (merge/verify)
|
||||||
|
answer the same question about the same paths instead of each keeping its own
|
||||||
|
literal list that can drift out of sync with the other - see AGENTS.md
|
||||||
|
invariant 8, and Gitea #30 for the incident that made the drift concrete
|
||||||
|
(the private-instance merge procedure hardcoded a three-path list that
|
||||||
|
`dist_cmd.py` had already outgrown).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# The stages whose content belongs to *this instance*, not the stack. Mirrors
|
||||||
|
# the sentence .gitignore already makes about raw/, kb/ and work/ being the
|
||||||
|
# repo's content, plus reports/ - only reports/CONTRACT.md is tracked there,
|
||||||
|
# the rest is gitignored, so restoring it is a no-op today. It stays in the
|
||||||
|
# set anyway: a set that is "almost" this one is the beginning of the same
|
||||||
|
# drift this module exists to end.
|
||||||
|
CONTENT_STAGES = ("kb", "raw", "work", "reports")
|
||||||
|
|
||||||
|
# Bare filenames `dist export` overwrites with a fresh stub rather than
|
||||||
|
# shipping the stack's own copy. Not stack-owned: an upstream merge takes the
|
||||||
|
# *local* side for these (they are the instance's own log/placeholder),
|
||||||
|
# while `dist export` writes a brand-new one regardless of either side.
|
||||||
|
EXPORT_STUB_NAMES = ("log.md", ".gitkeep")
|
||||||
|
|
||||||
|
# The single machinery filename directly under a content stage's own root.
|
||||||
|
_STAGE_CONTRACT_NAME = "CONTRACT.md"
|
||||||
|
|
||||||
|
|
||||||
|
def is_stack_owned(relative: str) -> bool:
|
||||||
|
"""Whether `relative` - a path under a content stage, e.g. "kb/CONTRACT.md"
|
||||||
|
or "kb/entities/COLLECTION.md.template" - is machinery: it ships with
|
||||||
|
every distribution, and it is the side an upstream merge keeps.
|
||||||
|
|
||||||
|
True for exactly two shapes:
|
||||||
|
|
||||||
|
- `<stage>/CONTRACT.md`, directly under a content stage's own root. Not
|
||||||
|
recursive: `kb/<collection>/COLLECTION.md` sits one level deeper and is
|
||||||
|
instance-owned (see kb/CONTRACT.md's collection-ownership split).
|
||||||
|
- Any path under a content stage ending in `.template` - by construction
|
||||||
|
the stack's own copy of something the instance adopts by renaming
|
||||||
|
(`kb/CONVENTIONS.md.template` and every `kb/<name>/COLLECTION.md.template`
|
||||||
|
today; a future stack-owned template under a content stage falls under
|
||||||
|
this rule automatically, with no code change here).
|
||||||
|
|
||||||
|
False for everything else under a content stage, `EXPORT_STUB_NAMES`
|
||||||
|
included - those are handled separately by whichever caller cares about
|
||||||
|
them, because the two callers disagree about which side wins for a stub.
|
||||||
|
"""
|
||||||
|
parts = relative.split("/")
|
||||||
|
if len(parts) < 2 or parts[0] not in CONTENT_STAGES:
|
||||||
|
return False
|
||||||
|
if relative.endswith(".template"):
|
||||||
|
return True
|
||||||
|
return len(parts) == 2 and parts[1] == _STAGE_CONTRACT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def is_export_stub(name: str) -> bool:
|
||||||
|
"""Whether `name` (a bare filename, not a path) is one `dist export`
|
||||||
|
overwrites with a fresh stub of its own rather than shipping verbatim."""
|
||||||
|
return name in EXPORT_STUB_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
# Root-relative paths `dist export` seeds once, from a template it owns, and
|
||||||
|
# which the instance owns exclusively from that point on. `dist upgrade`
|
||||||
|
# (Gitea #7) must never overwrite them, even though they sit in the release
|
||||||
|
# stamp's `files` block like any other planned file - the same shape as
|
||||||
|
# `EXPORT_STUB_NAMES` above, but keyed by full path rather than bare filename,
|
||||||
|
# since nothing else at the repo root gets this treatment and a bare-filename
|
||||||
|
# match would be too broad here.
|
||||||
|
#
|
||||||
|
# `.wikitool-kb.json` is `migrate done`'s state file: an upgrade that resets it
|
||||||
|
# declares a content shape nobody actually produced. `CHANGES.md` is the
|
||||||
|
# instance's own changelog, not the stack's - `dist export` seeds it from a
|
||||||
|
# blank template (`dist_templates/CHANGES.md`) the same way it seeds
|
||||||
|
# `kb/log.md`, and overwriting it on upgrade would erase every entry the
|
||||||
|
# instance ever wrote for itself.
|
||||||
|
UPGRADE_PRESERVED_PATHS = (".wikitool-kb.json", "CHANGES.md")
|
||||||
|
|
||||||
|
|
||||||
|
def is_upgrade_preserved(relative: str) -> bool:
|
||||||
|
"""Whether `relative` (a plan-relative path from the repo root, e.g.
|
||||||
|
"CHANGES.md") is one `dist upgrade` must never write."""
|
||||||
|
return relative in UPGRADE_PRESERVED_PATHS
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -37,6 +38,78 @@ _GIT_ENV = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _working_tree_state() -> str | None:
|
||||||
|
"""`git status --porcelain` for the checkout the tests live in, or None if
|
||||||
|
there is no git available to ask."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
_TREE_GUARD_MESSAGE = (
|
||||||
|
"A test wrote into the repository checkout instead of into its tmp_path.\n"
|
||||||
|
"`git status --porcelain` moved while the suite ran:\n\n"
|
||||||
|
" before:\n{before}\n"
|
||||||
|
" after:\n{after}\n\n"
|
||||||
|
"This is the class of bug Gitea #44 describes: code under test resolves a "
|
||||||
|
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
|
||||||
|
"directory the fixture handed it, so the write lands in the real tree. Fix "
|
||||||
|
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
|
||||||
|
"code path, never the symptom.\n"
|
||||||
|
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
|
||||||
|
"guard then checks after every test and fails on the first one that moves "
|
||||||
|
"the tree."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def repository_tree_guard():
|
||||||
|
"""Fail the run if the suite moved a file in the real checkout.
|
||||||
|
|
||||||
|
Two `git status` calls for the whole session, which is why this is on by
|
||||||
|
default: it catches the whole class rather than the one case that was
|
||||||
|
noticed. It compares before against after rather than demanding a clean
|
||||||
|
tree, so it says nothing about a developer's own uncommitted work.
|
||||||
|
|
||||||
|
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
|
||||||
|
trades a `git status` per test for a failure on the test that did it.
|
||||||
|
"""
|
||||||
|
before = _working_tree_state()
|
||||||
|
yield
|
||||||
|
after = _working_tree_state()
|
||||||
|
if before is None or after is None or before == after:
|
||||||
|
return
|
||||||
|
raise AssertionError(
|
||||||
|
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def per_test_tree_guard(repository_tree_guard):
|
||||||
|
"""The bisect half of `repository_tree_guard`, off unless asked for.
|
||||||
|
|
||||||
|
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
|
||||||
|
into "this test moved the tree", at the cost of a `git status` per test.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
before = _working_tree_state()
|
||||||
|
yield
|
||||||
|
after = _working_tree_state()
|
||||||
|
if before is not None and after is not None and before != after:
|
||||||
|
raise AssertionError(
|
||||||
|
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
"""Cut every test off from the machine it runs on.
|
"""Cut every test off from the machine it runs on.
|
||||||
@@ -159,13 +232,25 @@ def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def kb_dir(tmp_path: Path) -> Path:
|
def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
"""A minimal fixture kb/ with the standard collection layout, populated
|
"""A minimal fixture kb/ with the standard collection layout, populated
|
||||||
with a handful of pages covering entities/concepts/sources/comparisons.
|
with a handful of pages covering entities/concepts/sources/comparisons.
|
||||||
|
|
||||||
Every collection carries a COLLECTION.md, both because that is what makes it
|
Every collection carries a COLLECTION.md, both because that is what makes it
|
||||||
a collection and because the scanner must prove it skips them at a depth the
|
a collection and because the scanner must prove it skips them at a depth the
|
||||||
kb-root meta files never reach."""
|
kb-root meta files never reach.
|
||||||
|
|
||||||
|
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
|
||||||
|
collection over: code under test that resolves a path through
|
||||||
|
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
|
||||||
|
handed otherwise reaches the *real* repository. That was not theoretical
|
||||||
|
either - a test calling `kb_state.write_kb_state()` overwrote this
|
||||||
|
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
|
||||||
|
"no collection" for every fixture page, which left `unauthorised_labels`
|
||||||
|
with no working test at all (Gitea #44).
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
use_shipped_type_specs(monkeypatch)
|
||||||
kb = tmp_path / "kb"
|
kb = tmp_path / "kb"
|
||||||
for sub in ("entities/projects", "entities/systems", "entities/tools",
|
for sub in ("entities/projects", "entities/systems", "entities/tools",
|
||||||
"entities/technologies", "entities/people",
|
"entities/technologies", "entities/people",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from chemenu import config
|
from chemenu import config
|
||||||
from chemenu.commands import confidence_decay
|
from chemenu.commands import confidence_decay
|
||||||
from chemenu.commands.confidence_decay import FLOOR, compute_decay
|
from chemenu.commands.confidence_decay import FLOOR, compute_decay
|
||||||
from chemenu.frontmatter_io import read_page
|
from chemenu.frontmatter_io import read_page, write_page
|
||||||
|
|
||||||
|
|
||||||
def test_no_decay_at_zero_months():
|
def test_no_decay_at_zero_months():
|
||||||
@@ -68,3 +68,22 @@ def test_decay_skips_pages_without_a_base(decay_wiki):
|
|||||||
confidence_decay.confidence_decay(apply=True)
|
confidence_decay.confidence_decay(apply=True)
|
||||||
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
|
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
|
||||||
assert frontmatter["confidence"] == 0.9
|
assert frontmatter["confidence"] == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_decay_skips_decision_pages(decay_wiki):
|
||||||
|
"""A decision is not falsified by elapsed time, only by a later decision
|
||||||
|
superseding it - `concept_type: decision` is a categorical skip, not
|
||||||
|
something an old `modified` date should ever decay (Gitea #38)."""
|
||||||
|
decision_path = decay_wiki / "concepts" / "some-decision.md"
|
||||||
|
write_page(
|
||||||
|
decision_path,
|
||||||
|
{
|
||||||
|
"type": "types/concept.md", "concept_type": "decision",
|
||||||
|
"tags": [], "created": "2015-01-01", "modified": "2015-01-01",
|
||||||
|
"related": [], "sources": [], "confidence": 0.9, "confidence_base": 0.9,
|
||||||
|
},
|
||||||
|
"\n# some-decision\n",
|
||||||
|
)
|
||||||
|
confidence_decay.confidence_decay(apply=True)
|
||||||
|
frontmatter, _ = read_page(decision_path)
|
||||||
|
assert frontmatter["confidence"] == 0.9
|
||||||
|
|||||||
@@ -192,3 +192,29 @@ def test_an_undeclared_destination_authorises_nothing(kb_root):
|
|||||||
missing declaration to be filled in with a permissive default."""
|
missing declaration to be filled in with a permissive default."""
|
||||||
_authorising(kb_root, "entities", " concepts: [implements]")
|
_authorising(kb_root, "entities", " concepts: [implements]")
|
||||||
assert kb_collections.authorised_labels("entities", "sources") == set()
|
assert kb_collections.authorised_labels("entities", "sources") == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbound_on_a_collection_that_cannot_carry_labels_is_a_finding(kb_root):
|
||||||
|
"""`kb/sources/` is the live case: the `source` type-spec offers no
|
||||||
|
`related:`, so an `outbound:` block there authorises labels no page can
|
||||||
|
write. Left unchecked it reads as a licence and the label gets written into
|
||||||
|
the prose by hand instead - an identifier back in free text, which is what
|
||||||
|
labelled edges exist to end."""
|
||||||
|
_authorising(kb_root, "sources", " any: [is-evidence-for]", required=True)
|
||||||
|
issues = kb_collections.declaration_issues(kb_root)
|
||||||
|
assert any(
|
||||||
|
"kb/sources/COLLECTION.md" in issue and "no page type writing into kb/sources/" in issue
|
||||||
|
for issue in issues
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
|
||||||
|
_collection(kb_root, "sources", profile="sources", required=True)
|
||||||
|
_authorising(kb_root, "entities", " any: [uses]")
|
||||||
|
assert kb_collections.declaration_issues(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)
|
||||||
|
assert kb_collections.declaration_issues(kb_root) == []
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
|
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
docs_dir = root / "docs"
|
||||||
|
docs_dir.mkdir()
|
||||||
|
(docs_dir / "why-gates-are-code.md").write_text("# Why gates are code\n", encoding="utf-8")
|
||||||
|
|
||||||
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
|
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
|
||||||
path = root / relative
|
path = root / relative
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -156,6 +160,11 @@ def test_plan_never_includes_commonplace(repo):
|
|||||||
assert "commonplace" not in combined
|
assert "commonplace" not in combined
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_ships_docs_verbatim(repo):
|
||||||
|
plan = dist_cmd.build_plan()
|
||||||
|
assert plan["docs/why-gates-are-code.md"].content == "# Why gates are code\n"
|
||||||
|
|
||||||
|
|
||||||
def test_plan_never_includes_instructions_dev(repo):
|
def test_plan_never_includes_instructions_dev(repo):
|
||||||
"""instructions/dev/ - flat dev-only instructions and the nested skill
|
"""instructions/dev/ - flat dev-only instructions and the nested skill
|
||||||
that switches a session into tool-development mode - is pruned
|
that switches a session into tool-development mode - is pruned
|
||||||
@@ -421,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo):
|
|||||||
assert state["applied"] == []
|
assert state["applied"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
|
||||||
|
"""Exporting mid-candidate answers two different questions: the stamp says
|
||||||
|
what was actually exported (suffix included - "an export says what it
|
||||||
|
is"), the KB version says what shape the content is built for. A content
|
||||||
|
shape has no beta channel, so it must be the base."""
|
||||||
|
from chemenu import kb_state
|
||||||
|
|
||||||
|
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
|
||||||
|
plan = dist_cmd.build_plan()
|
||||||
|
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
|
||||||
|
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
|
||||||
|
assert stamp["version"] == "0.4.0-beta.2"
|
||||||
|
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
|
||||||
|
assert state["kb_version"] == "0.4.0"
|
||||||
|
|
||||||
|
|
||||||
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
||||||
(repo / "VERSION").unlink()
|
(repo / "VERSION").unlink()
|
||||||
target = tmp_path / "dist"
|
target = tmp_path / "dist"
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""Tests for `wikitool dist upgrade`: classification against the locally
|
||||||
|
installed release stamp, the write set, migration-chain reporting without
|
||||||
|
execution, and every refusal before anything is written. See Gitea #7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from chemenu import config, kb_state, version as version_mod
|
||||||
|
from chemenu.commands import dist_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(text: str) -> str:
|
||||||
|
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_stamp(path: Path, version: str, files: dict[str, str]) -> None:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema": version_mod.STAMP_SCHEMA,
|
||||||
|
"version": version,
|
||||||
|
"exported_at": "2026-01-01",
|
||||||
|
"source_repo": None,
|
||||||
|
"source_commit": None,
|
||||||
|
"release_url": None,
|
||||||
|
"update_url": version_mod.DEFAULT_UPDATE_URL,
|
||||||
|
"files": files,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_migration(directory: Path, target: str, slug: str) -> None:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / f"{target}-{slug}.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"type: types/instruction.md\n"
|
||||||
|
f"name: {target}-{slug}\n"
|
||||||
|
f"description: Migration to {target}.\n"
|
||||||
|
"manual: true\n"
|
||||||
|
f"migrates_to: {target}\n"
|
||||||
|
"migration_kind: assisted\n"
|
||||||
|
"obligation: required\n"
|
||||||
|
"---\n\n# Migration\n\nSteps.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_kb_version(root: Path, version: str) -> None:
|
||||||
|
(root / kb_state.KB_STATE_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "kb_version": version, "applied": []}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""A minimal tarball instance: VERSION 1.0.0, a local release stamp
|
||||||
|
recording two tracked files exactly as installed, content stays at
|
||||||
|
1.0.0 with nothing outstanding, and no .git directory (the WARN path
|
||||||
|
for the dirty-tree check)."""
|
||||||
|
root = tmp_path / "instance"
|
||||||
|
root.mkdir()
|
||||||
|
monkeypatch.setattr(config, "ROOT", root)
|
||||||
|
|
||||||
|
(root / "VERSION").write_text("1.0.0\n", encoding="utf-8")
|
||||||
|
(root / "AGENTS.md").write_text("core\n", encoding="utf-8")
|
||||||
|
(root / "tools").mkdir()
|
||||||
|
(root / "tools" / "wikitool").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
|
||||||
|
files = {
|
||||||
|
"AGENTS.md": _digest("core\n"),
|
||||||
|
"tools/wikitool": _digest("#!/bin/sh\n"),
|
||||||
|
}
|
||||||
|
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, "1.0.0", files)
|
||||||
|
set_kb_version(root, "1.0.0")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _release(tmp_path: Path, name: str, version: str, files_content: dict[str, str]) -> Path:
|
||||||
|
"""A second, independent tree shaped like a `dist export` output: VERSION,
|
||||||
|
a release stamp whose `files` block matches `files_content` exactly, and
|
||||||
|
the files themselves."""
|
||||||
|
root = tmp_path / name
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for relative, content in files_content.items():
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
files[relative] = _digest(content)
|
||||||
|
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, version, files)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# --- classification / dry-run -----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_classifies_every_case_and_writes_nothing(instance, tmp_path):
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
(instance / "tools" / "wikitool").unlink()
|
||||||
|
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{
|
||||||
|
"AGENTS.md": "core\n", # locally modified
|
||||||
|
"tools/wikitool": "#!/bin/sh\n", # locally deleted
|
||||||
|
"types/entity.md": "new page type\n", # new in the release
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dist_cmd.run_upgrade(release, dry_run=True)
|
||||||
|
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
assert not (instance / "tools" / "wikitool").exists()
|
||||||
|
assert not (instance / "types" / "entity.md").exists()
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_file_is_reported_and_left_alone_without_prune(instance, tmp_path):
|
||||||
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||||
|
# tools/wikitool is in the old stamp but absent from the new one.
|
||||||
|
dist_cmd.run_upgrade(release, dry_run=True)
|
||||||
|
assert (instance / "tools" / "wikitool").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
# --- local changes are never silently overwritten ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_locally_modified_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_locally_deleted_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
||||||
|
(instance / "tools" / "wikitool").unlink()
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
assert not (instance / "tools" / "wikitool").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp_path):
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, keep_local=True)
|
||||||
|
|
||||||
|
# The locally changed file is untouched...
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
# ...but everything unchanged/new was still written.
|
||||||
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh\n"
|
||||||
|
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the write set -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_and_new_files_are_written_silently(instance, tmp_path):
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
||||||
|
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
||||||
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||||
|
assert stamp["version"] == "1.1.0"
|
||||||
|
assert stamp["files"]["types/entity.md"] == _digest("new\n")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||||
|
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
|
||||||
|
instance, tmp_path, preserved
|
||||||
|
):
|
||||||
|
"""The write set is the new stamp's `files` block minus what an export
|
||||||
|
re-seeds every time or seeds once and the instance owns from then on -
|
||||||
|
this is the AGENTS.md invariant 8 test: no separate literal list here,
|
||||||
|
only `chemenu.ownership`."""
|
||||||
|
(instance / "CHANGES.md").write_text("instance's own changelog\n", encoding="utf-8")
|
||||||
|
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "kb_version": "1.0.0", "applied": [{"migration": "x"}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{
|
||||||
|
"AGENTS.md": "core\n",
|
||||||
|
"tools/wikitool": "#!/bin/sh\n",
|
||||||
|
preserved: "a fresh stub from the new release\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
if preserved == "CHANGES.md":
|
||||||
|
assert (instance / preserved).read_text(encoding="utf-8") == "instance's own changelog\n"
|
||||||
|
elif preserved == ".wikitool-kb.json":
|
||||||
|
state = json.loads((instance / preserved).read_text())
|
||||||
|
assert state["applied"] == [{"migration": "x"}]
|
||||||
|
else:
|
||||||
|
assert not (instance / preserved).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --- migration chain: reported, never run -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_chain_is_reported_but_never_executed(instance, tmp_path):
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
write_migration(release / "instructions" / "migrations", "1.1.0", "some-change")
|
||||||
|
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
# The chain is reported, not applied: kb_version has not moved.
|
||||||
|
assert kb_state.read_kb_version().base == version_mod.Version(1, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outstanding_local_migration_blocks_before_touching_the_source(instance, tmp_path):
|
||||||
|
"""A migration owed against the *installed* machinery must be finished
|
||||||
|
first - the source is never even opened."""
|
||||||
|
migrations = instance / "instructions" / "migrations"
|
||||||
|
write_migration(migrations, "1.0.0", "not-yet-done")
|
||||||
|
monkey_target = instance / "does-not-exist" # never read if this check fires first
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(monkey_target)
|
||||||
|
|
||||||
|
|
||||||
|
# --- preconditions -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_local_stamp_blocks(instance, tmp_path):
|
||||||
|
(instance / version_mod.RELEASE_STAMP_FILENAME).unlink()
|
||||||
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_kb_version_blocks(instance, tmp_path):
|
||||||
|
(instance / kb_state.KB_STATE_FILENAME).unlink()
|
||||||
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prerelease_source_is_refused_without_pre_flag(instance, tmp_path):
|
||||||
|
release = _release(tmp_path, "release", "1.1.0-beta.1", {"AGENTS.md": "core\n"})
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_flag_allows_a_prerelease_source(instance, tmp_path):
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0-beta.1",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, allow_pre=True)
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_is_refused(instance, tmp_path):
|
||||||
|
release = _release(tmp_path, "release", "0.9.0", {"AGENTS.md": "core\n"})
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_version_is_a_noop(instance, tmp_path):
|
||||||
|
release = _release(tmp_path, "release", "1.0.0", {"AGENTS.md": "core\n"})
|
||||||
|
dist_cmd.run_upgrade(release) # must not raise
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_working_tree_blocks(instance, tmp_path):
|
||||||
|
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=instance, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=instance, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=instance, check=True)
|
||||||
|
(instance / "untracked.txt").write_text("dirty\n", encoding="utf-8")
|
||||||
|
|
||||||
|
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
assert not (instance / "types").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_that_does_not_exist_is_refused(instance, tmp_path):
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(tmp_path / "nowhere")
|
||||||
|
|
||||||
|
|
||||||
|
# --- prune -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_removes_only_removed_files_still_unchanged_since_install(instance, tmp_path):
|
||||||
|
(instance / "extra.txt").write_text("shipped once, edited since\n", encoding="utf-8")
|
||||||
|
old_stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||||
|
old_stamp["files"]["extra.txt"] = _digest("shipped once, unedited\n") # deliberately stale
|
||||||
|
old_stamp["files"]["gone.txt"] = _digest("also shipped once\n")
|
||||||
|
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(old_stamp), encoding="utf-8")
|
||||||
|
(instance / "gone.txt").write_text("also shipped once\n", encoding="utf-8")
|
||||||
|
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, prune=True)
|
||||||
|
|
||||||
|
# gone.txt matched its recorded digest -> pruned.
|
||||||
|
assert not (instance / "gone.txt").exists()
|
||||||
|
# extra.txt was locally edited relative to its recorded digest -> kept.
|
||||||
|
assert (instance / "extra.txt").read_text(encoding="utf-8") == "shipped once, edited since\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- tarball sources ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _pack(release_dir: Path, archive: Path) -> None:
|
||||||
|
with tarfile.open(archive, "w:gz") as tf:
|
||||||
|
tf.add(release_dir, arcname=release_dir.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tarball_with_more_than_one_top_level_entry_is_refused(instance, tmp_path):
|
||||||
|
scratch = tmp_path / "scratch"
|
||||||
|
(scratch / "a").mkdir(parents=True)
|
||||||
|
(scratch / "b").mkdir(parents=True)
|
||||||
|
(scratch / "a" / "x.txt").write_text("x\n", encoding="utf-8")
|
||||||
|
(scratch / "b" / "y.txt").write_text("y\n", encoding="utf-8")
|
||||||
|
archive = tmp_path / "bad.tar.gz"
|
||||||
|
with tarfile.open(archive, "w:gz") as tf:
|
||||||
|
tf.add(scratch / "a", arcname="a")
|
||||||
|
tf.add(scratch / "b", arcname="b")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(archive)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tarball_source_is_extracted_and_applied(instance, tmp_path):
|
||||||
|
release_dir = _release(
|
||||||
|
tmp_path, "chemenu-stack-1.1.0", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
||||||
|
_pack(release_dir, archive)
|
||||||
|
|
||||||
|
dist_cmd.run_upgrade(archive)
|
||||||
|
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tarball_sha256_sidecar_mismatch_is_refused(instance, tmp_path):
|
||||||
|
release_dir = _release(
|
||||||
|
tmp_path, "chemenu-stack-1.1.0", "1.1.0", {"AGENTS.md": "core\n"}
|
||||||
|
)
|
||||||
|
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
||||||
|
_pack(release_dir, archive)
|
||||||
|
(archive.with_name(archive.name + ".sha256")).write_text(
|
||||||
|
"0" * 64 + " chemenu-stack-1.1.0.tar.gz\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(archive)
|
||||||
@@ -100,6 +100,30 @@ def test_install_md_is_checked_too(tmp_path, monkeypatch):
|
|||||||
assert any("`doctor`" in issue for issue in issues)
|
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():
|
def test_legacy_type_blocks_are_absent():
|
||||||
assert docs_verify.check_legacy_type_blocks() == []
|
assert docs_verify.check_legacy_type_blocks() == []
|
||||||
|
|
||||||
@@ -190,6 +214,12 @@ def test_this_repos_boundary_is_accounted_for():
|
|||||||
|
|
||||||
|
|
||||||
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
||||||
|
"""A changelog with `current` as the topmost entry and `previous` as the
|
||||||
|
last release beneath it. `current` is normally an open candidate
|
||||||
|
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
|
||||||
|
release** (`version_mod.last_release`), which skips right past a topmost
|
||||||
|
entry that is itself already a release (that one's crossing, if any, was
|
||||||
|
already checked while it was still the open candidate)."""
|
||||||
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
||||||
(tmp_path / "CHANGES.md").write_text(
|
(tmp_path / "CHANGES.md").write_text(
|
||||||
"# Changelog\n\n---\n\n"
|
"# Changelog\n\n---\n\n"
|
||||||
@@ -207,28 +237,41 @@ def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: s
|
|||||||
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
||||||
"""`version check` tells an instance it must migrate; without this, that is
|
"""`version check` tells an instance it must migrate; without this, that is
|
||||||
where the trail ends."""
|
where the trail ends."""
|
||||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||||
issues = docs_verify.check_migration_for_boundary()
|
issues = docs_verify.check_migration_for_boundary()
|
||||||
assert any("2.0.0" in issue and "must migrate" in issue for issue in issues)
|
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
||||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
|
||||||
|
"""Regression for finding #2: comparing against the entry *beneath* the
|
||||||
|
newest one (rather than the last release) would find no boundary between
|
||||||
|
two betas of the same candidate - and would also, wrongly, re-flag an
|
||||||
|
already-fixed release forever. Once `current` is itself a release,
|
||||||
|
`last_release` returns it directly, so there is nothing left to compare."""
|
||||||
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||||
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
||||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
"""The document targets the candidate's *base* (`2.0.0`), not its full
|
||||||
|
pre-release form - matching what `version bump` looks for."""
|
||||||
|
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||||
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
||||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||||
@@ -243,16 +286,16 @@ def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkey
|
|||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_migration_for_boundary() == []
|
assert docs_verify.check_migration_for_boundary() == []
|
||||||
issues = docs_verify.check_breaking_change_for_boundary()
|
issues = docs_verify.check_breaking_change_for_boundary()
|
||||||
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
|
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
||||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +303,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
|||||||
from chemenu import version as version_mod
|
from chemenu import version as version_mod
|
||||||
|
|
||||||
_boundary_tree(
|
_boundary_tree(
|
||||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||||
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
||||||
)
|
)
|
||||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||||
|
|||||||
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
|
|||||||
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_running_candidate_is_named_as_such(instance):
|
||||||
|
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
||||||
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
||||||
|
assert "0.2.0-beta.1" in detail
|
||||||
|
assert "candidate" in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
||||||
|
"""Regression for finding #3: comparing the chain against the raw
|
||||||
|
candidate would sort `2.0.0` (the migration's target) *before*
|
||||||
|
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
||||||
|
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||||
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
||||||
|
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
||||||
|
migrations.mkdir(parents=True, exist_ok=True)
|
||||||
|
(migrations / "2.0.0-retype.md").write_text(
|
||||||
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
checks = doctor.run_doctor()
|
||||||
|
assert _status(checks, "kb-version") == "WARN"
|
||||||
|
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
||||||
|
|
||||||
|
|
||||||
def test_no_collections_at_all_fails_structure(instance):
|
def test_no_collections_at_all_fails_structure(instance):
|
||||||
"""Removing one collection is a legitimate state - collections are
|
"""Removing one collection is a legitimate state - collections are
|
||||||
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ def test_yes_is_a_finding_even_after_the_gate_refused():
|
|||||||
assert "no longer exists" in rule.findings[0]["reason"]
|
assert "no longer exists" in rule.findings[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yes_on_a_command_that_still_has_it_is_not_a_finding():
|
||||||
|
"""`--yes` was removed from `publish` only (chemenu/git_publish.py); `rm
|
||||||
|
--page X --yes` is a live, documented flag. REMOVED_FLAGS names the
|
||||||
|
command the flag was removed from - the rule must check the call's own
|
||||||
|
`command` against it, not just scan every argument for the literal
|
||||||
|
string. A real trace (`publish-cleanup/u3`) shows what missing this
|
||||||
|
check costs: 27 ordinary `rm --yes` calls scored as invariant
|
||||||
|
violations."""
|
||||||
|
records = [call("2026-08-23T10:00:00Z", "rm", "--page", "X", "--yes")]
|
||||||
|
assert rule_by_id(records, "gate-not-self-opened").passed
|
||||||
|
|
||||||
|
|
||||||
def test_override_budget_needs_its_own_gate_not_another_one():
|
def test_override_budget_needs_its_own_gate_not_another_one():
|
||||||
"""Being refused by one gate does not license opening a different one."""
|
"""Being refused by one gate does not license opening a different one."""
|
||||||
records = [
|
records = [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from chemenu.commands._util import EXIT_NEEDS_CLEARANCE
|
|||||||
from chemenu.commands.git_publish import (
|
from chemenu.commands.git_publish import (
|
||||||
DEFAULT_MASS_UPDATE_THRESHOLD,
|
DEFAULT_MASS_UPDATE_THRESHOLD,
|
||||||
GATE_EXEMPT_PREFIXES,
|
GATE_EXEMPT_PREFIXES,
|
||||||
|
STACK_MACHINERY_NOTE,
|
||||||
YES_REMOVED_MESSAGE,
|
YES_REMOVED_MESSAGE,
|
||||||
FileChange,
|
FileChange,
|
||||||
attention_notes,
|
attention_notes,
|
||||||
@@ -29,6 +30,7 @@ from chemenu.commands.git_publish import (
|
|||||||
rerun_command,
|
rerun_command,
|
||||||
scale_line,
|
scale_line,
|
||||||
sync_command,
|
sync_command,
|
||||||
|
touches_stack_machinery,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,6 +292,26 @@ def test_gate_message_names_generated_files_as_their_own_reason():
|
|||||||
assert "kb/provenance.md" not in message
|
assert "kb/provenance.md" not in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_stack_machinery_detects_the_version_parts_scope():
|
||||||
|
"""The same boundary version-parts.md names for the stack version: tools/,
|
||||||
|
types/, instructions/, AGENTS.md, and any <stage>/CONTRACT.md."""
|
||||||
|
assert touches_stack_machinery(["instructions/dev/issue-tracking.md"])
|
||||||
|
assert touches_stack_machinery(["tools/chemenu/commands/git_publish.py"])
|
||||||
|
assert touches_stack_machinery(["types/instruction.md"])
|
||||||
|
assert touches_stack_machinery(["AGENTS.md"])
|
||||||
|
assert touches_stack_machinery(["kb/CONTRACT.md"])
|
||||||
|
assert touches_stack_machinery(["raw/CONTRACT.md"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_stack_machinery_excludes_ordinary_content():
|
||||||
|
"""kb/ content, docs/ prose and work/ scratch carry no normative sentence
|
||||||
|
and are not what the closing-phase reminder is about."""
|
||||||
|
assert not touches_stack_machinery(["kb/entities/systems/Foo.md"])
|
||||||
|
assert not touches_stack_machinery(["docs/why-gates-are-code.md"])
|
||||||
|
assert not touches_stack_machinery(["work/ingest-x/extract-0.md"])
|
||||||
|
assert not touches_stack_machinery([])
|
||||||
|
|
||||||
|
|
||||||
# --- publish_command integration: a real git repo + a local bare remote ---
|
# --- publish_command integration: a real git repo + a local bare remote ---
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +378,24 @@ def test_below_threshold_publish_goes_straight_through(repo):
|
|||||||
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
|
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_notes_stack_machinery_after_success(repo, capsys):
|
||||||
|
"""The closing-phase reminder lands exactly once, after the OK line, and
|
||||||
|
only when the changeset actually falls under version-parts.md's scope."""
|
||||||
|
(repo / "instructions").mkdir()
|
||||||
|
(repo / "instructions/example.md").write_text("x\n", encoding="utf-8")
|
||||||
|
_publish(message="touch instructions")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert STACK_MACHINERY_NOTE in out
|
||||||
|
assert out.count(STACK_MACHINERY_NOTE) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_stays_quiet_for_ordinary_content(repo, capsys):
|
||||||
|
_write_files(repo, 1)
|
||||||
|
_publish(message="ordinary content")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert STACK_MACHINERY_NOTE not in out
|
||||||
|
|
||||||
|
|
||||||
def test_at_threshold_publish_asks_for_clearance_and_stages_nothing(repo):
|
def test_at_threshold_publish_asks_for_clearance_and_stages_nothing(repo):
|
||||||
_write_files(repo, 10)
|
_write_files(repo, 10)
|
||||||
with pytest.raises(typer.Exit) as excinfo:
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
|||||||
@@ -74,3 +74,23 @@ def test_wiki_author_overrides_the_git_identity(tmp_path: Path,
|
|||||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
|
monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
|
||||||
assert config.default_author() == "Env Override"
|
assert config.default_author() == "Env Override"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_dir_repoints_the_configured_root_at_its_own_tree(kb_dir: Path, tmp_path: Path):
|
||||||
|
"""The other half of the isolation, and the one `kb_dir` was missing until
|
||||||
|
Gitea #44: a fixture that builds a corpus but leaves `config.ROOT` on the
|
||||||
|
real checkout hands every `config.KB_DIR` lookup the developer's own wiki -
|
||||||
|
which is how a test overwrote the repository's `.wikitool-kb.json`."""
|
||||||
|
assert config.ROOT == tmp_path
|
||||||
|
assert config.KB_DIR == kb_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_dir_repoints_the_configured_root_at_its_own_tree(raw_dir: Path, tmp_path: Path):
|
||||||
|
assert config.ROOT == tmp_path
|
||||||
|
assert config.RAW_DIR == raw_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_corpus_fixtures_keep_the_shipped_type_specs_reachable(kb_dir: Path):
|
||||||
|
"""Repointing `ROOT` moves `TYPES_DIR` with it, so the repoint has to be
|
||||||
|
paired with `use_shipped_type_specs()` or no page type resolves at all."""
|
||||||
|
assert (config.TYPES_DIR / "entity.md").is_file()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from chemenu import config
|
import pytest
|
||||||
|
|
||||||
|
from chemenu import config, kb_state
|
||||||
from chemenu.commands.lint import (
|
from chemenu.commands.lint import (
|
||||||
|
HARD_ERROR_KEYS,
|
||||||
|
hard_error_keys,
|
||||||
has_hard_errors,
|
has_hard_errors,
|
||||||
lint_command,
|
lint_command,
|
||||||
render_markdown,
|
render_markdown,
|
||||||
@@ -11,6 +15,7 @@ from chemenu.commands.lint import (
|
|||||||
)
|
)
|
||||||
from chemenu.frontmatter_io import write_page
|
from chemenu.frontmatter_io import write_page
|
||||||
from chemenu.provenance import cite_id, render_cite_block
|
from chemenu.provenance import cite_id, render_cite_block
|
||||||
|
from chemenu.version import Version
|
||||||
|
|
||||||
|
|
||||||
def test_lint_detects_unparsable_frontmatter(kb_dir):
|
def test_lint_detects_unparsable_frontmatter(kb_dir):
|
||||||
@@ -472,3 +477,195 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
|||||||
)
|
)
|
||||||
report = run_lint(kb_dir)
|
report = run_lint(kb_dir)
|
||||||
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []
|
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
||||||
|
#
|
||||||
|
# These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
|
||||||
|
# to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
|
||||||
|
# (Gitea #44), so the gate is read off the fixture tree; before it did, these
|
||||||
|
# four ran against the real repository's state file and one of them overwrote
|
||||||
|
# it.
|
||||||
|
|
||||||
|
|
||||||
|
def _page_with_an_unlabelled_edge(kb_dir):
|
||||||
|
"""A `related:` entry that is a bare title rather than a `label: title`
|
||||||
|
mapping - the shape every page was in before the 4.0.0 migration."""
|
||||||
|
write_page(
|
||||||
|
kb_dir / "entities/tools/bare-edge.md",
|
||||||
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||||
|
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
||||||
|
"provenance": "general", "summary": "One edge whose label was never declared."},
|
||||||
|
"\n# bare-edge\n\nAn edge without a label.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
|
||||||
|
"""The window the migration document describes: the machinery has landed,
|
||||||
|
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
||||||
|
refuse the very tree the migration tells the instance to publish unit by
|
||||||
|
unit."""
|
||||||
|
_page_with_an_unlabelled_edge(kb_dir)
|
||||||
|
kb_state.write_kb_state(Version(3, 0, 0), [])
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert report["unlabelled_edges"] != []
|
||||||
|
assert "unlabelled_edges" not in hard_error_keys()
|
||||||
|
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
||||||
|
# hard errors of its own, so asserting on the whole report would prove
|
||||||
|
# nothing about the gate.
|
||||||
|
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
|
||||||
|
"""Once the migration is recorded, a bare title is no longer a page waiting
|
||||||
|
its turn - it is an edge whose author did not say what it asserts."""
|
||||||
|
_page_with_an_unlabelled_edge(kb_dir)
|
||||||
|
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert report["unlabelled_edges"] != []
|
||||||
|
assert "unlabelled_edges" in hard_error_keys()
|
||||||
|
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
|
||||||
|
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
||||||
|
write_page(
|
||||||
|
kb_dir / "entities/tools/off-menu.md",
|
||||||
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||||
|
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||||
|
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||||
|
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||||
|
)
|
||||||
|
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert report["unauthorised_labels"] != []
|
||||||
|
assert "unauthorised_labels" in hard_error_keys()
|
||||||
|
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
|
||||||
|
kb_dir, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""`run_lint()` judges the tree it was handed, not the configured corpus.
|
||||||
|
|
||||||
|
The collection lookup used to resolve a page against `config.KB_DIR`; a
|
||||||
|
page anywhere else raised `ValueError`, read back as "no collection", and
|
||||||
|
the label check skipped the edge without a word. That is why
|
||||||
|
`unauthorised_labels` was untested in practice before Gitea #44 - every
|
||||||
|
fixture tree was somewhere else. Here `ROOT` deliberately points away from
|
||||||
|
the tree under lint, which is the case the old code got wrong.
|
||||||
|
"""
|
||||||
|
write_page(
|
||||||
|
kb_dir / "entities/tools/off-menu.md",
|
||||||
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||||
|
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||||
|
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||||
|
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||||
|
)
|
||||||
|
elsewhere = tmp_path / "elsewhere"
|
||||||
|
elsewhere.mkdir()
|
||||||
|
monkeypatch.setattr(config, "ROOT", elsewhere)
|
||||||
|
assert config.KB_DIR != kb_dir
|
||||||
|
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert {
|
||||||
|
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
|
||||||
|
} in report["unauthorised_labels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
|
||||||
|
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
||||||
|
current shape rather than migrating into it - so there is no outstanding
|
||||||
|
migration for a gated finding to be the noise of."""
|
||||||
|
assert kb_state.read_kb_version() is None
|
||||||
|
assert hard_error_keys() == HARD_ERROR_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
# --- `redundant_see_also` --------------------------------------------------
|
||||||
|
#
|
||||||
|
# `see-also` is the catalogue's declared last resort. The finding is about the
|
||||||
|
# case where the *other* page already said something specific about the same
|
||||||
|
# pair, so the weak edge carries nothing the inbound view did not already
|
||||||
|
# render. Measured once on this repo's corpus, that was 57 of 180 see-also
|
||||||
|
# edges - which is why it is worth a check rather than a habit.
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(kb_dir, forward, backward):
|
||||||
|
"""Two tool pages asserting `forward` and `backward` about each other."""
|
||||||
|
for name, edge in (("nearside", forward), ("farside", backward)):
|
||||||
|
other = "farside" if name == "nearside" else "nearside"
|
||||||
|
write_page(
|
||||||
|
kb_dir / f"entities/tools/{name}.md",
|
||||||
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
||||||
|
"created": "2026-09-04", "modified": "2026-09-04",
|
||||||
|
"related": [] if edge is None else [{edge: other}],
|
||||||
|
"sources": [], "confidence": 0.8, "provenance": "general",
|
||||||
|
"summary": f"One half of a pair, asserting {edge} about the other."},
|
||||||
|
f"\n# {name}\n\nHalf a pair.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_see_also_is_redundant_when_the_other_page_asserts_something_specific(kb_dir):
|
||||||
|
"""`nearside see-also farside` beside `farside depends-on nearside`: the
|
||||||
|
labelled edge already shows on both pages, so the weak one says nothing."""
|
||||||
|
_pair(kb_dir, "see-also", "depends-on")
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert {
|
||||||
|
"page": "nearside", "target": "farside", "reverse_label": "depends-on"
|
||||||
|
} in report["redundant_see_also"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_see_also_with_no_reverse_edge_at_all_is_not_redundant(kb_dir):
|
||||||
|
"""The ordinary case the label exists for - nothing more specific fits, and
|
||||||
|
the other page says nothing back."""
|
||||||
|
_pair(kb_dir, "see-also", None)
|
||||||
|
assert [
|
||||||
|
i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "nearside"
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mutual_see_also_pair_is_not_reported_here(kb_dir):
|
||||||
|
"""Two weak edges about one pair is a different finding - a mirror, which
|
||||||
|
the catalogue's 'direction is authored, never mirrored' rule covers and a
|
||||||
|
corpus sweep resolves. This check must not claim it: it is about a weak
|
||||||
|
edge standing beside a *specific* one, and reporting the mutual case here
|
||||||
|
would tell an author to drop an edge without saying which."""
|
||||||
|
_pair(kb_dir, "see-also", "see-also")
|
||||||
|
assert [
|
||||||
|
i for i in run_lint(kb_dir)["redundant_see_also"]
|
||||||
|
if i["page"] in ("nearside", "farside")
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_specific_edge_is_never_the_one_reported(kb_dir):
|
||||||
|
"""Only the `see-also` side is a finding. Reporting the labelled edge too
|
||||||
|
would make the pair unfixable - dropping both loses the assertion."""
|
||||||
|
_pair(kb_dir, "see-also", "depends-on")
|
||||||
|
assert [
|
||||||
|
i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "farside"
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_redundant_see_also_is_advisory_at_every_kb_version(kb_dir):
|
||||||
|
"""Redundant, not wrong - and the check arrived long after the corpora it
|
||||||
|
judges, so promoting it would turn every existing instance red on the
|
||||||
|
upgrade that shipped it. Unlike `unlabelled_edges` there is no version at
|
||||||
|
which it becomes an error, so it is not migration-gated either."""
|
||||||
|
_pair(kb_dir, "see-also", "depends-on")
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert report["redundant_see_also"] != []
|
||||||
|
for version in (Version(3, 0, 0), Version(4, 0, 0), Version(5, 0, 0)):
|
||||||
|
kb_state.write_kb_state(version, [])
|
||||||
|
assert "redundant_see_also" not in hard_error_keys()
|
||||||
|
assert has_hard_errors({"redundant_see_also": report["redundant_see_also"]}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_redundant_see_also_reaches_the_rendered_report_and_the_summary(kb_dir):
|
||||||
|
"""A finding nobody prints is a finding nobody acts on. `render_summary`
|
||||||
|
drops every empty section, so this also proves the section is not empty."""
|
||||||
|
_pair(kb_dir, "see-also", "depends-on")
|
||||||
|
report = run_lint(kb_dir)
|
||||||
|
assert "Redundant see-also" in render_markdown(report)
|
||||||
|
summary = render_summary(report)
|
||||||
|
assert "Redundant see-also" in summary
|
||||||
|
assert "[[nearside]]" in summary and "depends-on" in summary
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import typer
|
|||||||
|
|
||||||
from chemenu import config, kb_state
|
from chemenu import config, kb_state
|
||||||
from chemenu.commands import migrate_cmd
|
from chemenu.commands import migrate_cmd
|
||||||
from chemenu.version import Version
|
from chemenu.version import Version, VersionError
|
||||||
|
|
||||||
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
||||||
|
|
||||||
@@ -108,6 +108,19 @@ def test_status_lists_the_chain_in_order(instance, capsys):
|
|||||||
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_chain_still_reaches_a_target_matching_a_running_candidate(instance, capsys):
|
||||||
|
"""Regression for finding #3: a migration targeting `2.0.0` must still be
|
||||||
|
owed while `VERSION` is the running candidate `2.0.0-beta.1` - `2.0.0` sorts
|
||||||
|
*above* its own candidate, so comparing against the raw pre-release would
|
||||||
|
drop it out of the chain right when the machinery that owes it installs."""
|
||||||
|
(instance / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||||
|
set_kb_version(instance, "1.7.0")
|
||||||
|
migrate_cmd.status_command(json_out=True)
|
||||||
|
result = json.loads(capsys.readouterr().out)
|
||||||
|
assert result["stack_version"] == "2.0.0-beta.1"
|
||||||
|
assert [m["migrates_to"] for m in result["pending"]] == ["2.0.0"]
|
||||||
|
|
||||||
|
|
||||||
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
||||||
migrate_cmd.list_command(json_out=True)
|
migrate_cmd.list_command(json_out=True)
|
||||||
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
||||||
@@ -155,6 +168,16 @@ def test_done_without_a_declared_kb_version_is_refused(instance):
|
|||||||
# --- baseline --------------------------------------------------------------
|
# --- baseline --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_kb_version_refuses_a_pre_release(instance):
|
||||||
|
"""A content shape has no beta channel - only the stack version does."""
|
||||||
|
set_kb_version(instance, "1.3.1")
|
||||||
|
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "kb_version": "1.4.0-beta.1", "applied": []}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
with pytest.raises(VersionError):
|
||||||
|
kb_state.read_kb_version()
|
||||||
|
|
||||||
|
|
||||||
def test_baseline_declares_the_version_once(instance):
|
def test_baseline_declares_the_version_once(instance):
|
||||||
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
||||||
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def test_get_page_ref_fields_reads_the_type_spec():
|
|||||||
assert resolver.get_page_ref_fields("types/entity.md") == ["related", "sources"]
|
assert resolver.get_page_ref_fields("types/entity.md") == ["related", "sources"]
|
||||||
assert resolver.get_page_ref_fields("types/concept.md") == ["related", "sources"]
|
assert resolver.get_page_ref_fields("types/concept.md") == ["related", "sources"]
|
||||||
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts"]
|
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts"]
|
||||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities"]
|
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related"]
|
||||||
|
|
||||||
|
|
||||||
def test_page_ref_fields_exist_in_the_type_schema():
|
def test_page_ref_fields_exist_in_the_type_schema():
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
"""Tests for `wikitool upstream merge`/`upstream verify` - the code procedure
|
||||||
|
that replaces private-instance.md's prose merge script (Gitea #30).
|
||||||
|
|
||||||
|
Two real git repos stand in for a private instance (`repo`, remote name
|
||||||
|
`upstream`) and the public repo it takes updates from (`upstream`, a plain
|
||||||
|
repo committed to directly - a fetch-only remote does not need to be bare for
|
||||||
|
`git fetch` to work against it). Each scenario diverges the two by committing
|
||||||
|
independently on each side, exactly like a real fetch-only upstream would.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from chemenu import config, ownership
|
||||||
|
from chemenu.commands import git_publish, upstream_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _git(root, *args):
|
||||||
|
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _write(root, relative, content):
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(root, message):
|
||||||
|
_git(root, "add", "-A")
|
||||||
|
_git(root, "commit", "-m", message)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_repos(tmp_path, monkeypatch):
|
||||||
|
"""`repo`, a private instance, with a fetch-only `upstream` remote pointing
|
||||||
|
at a second, independent repo. Both start from the same seed commit -
|
||||||
|
kb/CONTRACT.md, kb/CONVENTIONS.md(.template), kb/entities/COLLECTION.md,
|
||||||
|
raw/CONTRACT.md, work/CONTRACT.md, reports/CONTRACT.md, and one tools/
|
||||||
|
file - which is what a private instance looks like right after the
|
||||||
|
private-instance.md setup: the tracked machinery, plus its own filled
|
||||||
|
instance files layered on top.
|
||||||
|
"""
|
||||||
|
seed = tmp_path / "seed"
|
||||||
|
seed.mkdir()
|
||||||
|
_git(seed, "init", "-b", "main")
|
||||||
|
_git(seed, "config", "user.name", "Seed")
|
||||||
|
_git(seed, "config", "user.email", "seed@example.com")
|
||||||
|
# .wikitool-remotes.json is gitignored in the real repo (it is per-checkout,
|
||||||
|
# see config.PUBLISH_REMOTES_FILENAME) - without this, dropping one into the
|
||||||
|
# fixture during a test would show up as an untracked file and trip the
|
||||||
|
# dirty-working-tree precondition for a reason that has nothing to do with
|
||||||
|
# what that test is checking.
|
||||||
|
# Mirrors the real .gitignore in the two ways that matter here:
|
||||||
|
# `.wikitool-remotes.json` is per-checkout (dropping one in during a test
|
||||||
|
# must not read as a dirty tree), and `reports/` is derived output that is
|
||||||
|
# ignored except for its contract - which is what makes a content stage
|
||||||
|
# able to hold local, non-recomputable data a merge must not touch.
|
||||||
|
_write(
|
||||||
|
seed,
|
||||||
|
".gitignore",
|
||||||
|
f"/{config.PUBLISH_REMOTES_FILENAME}\n/reports/*\n!/reports/CONTRACT.md\n",
|
||||||
|
)
|
||||||
|
_write(seed, "kb/CONTRACT.md", "stack kb contract v1\n")
|
||||||
|
_write(seed, "kb/CONVENTIONS.md.template", "template v1\n")
|
||||||
|
_write(seed, "kb/CONVENTIONS.md", "instance conventions v1\n")
|
||||||
|
_write(seed, "kb/entities/COLLECTION.md", "instance collection contract v1\n")
|
||||||
|
_write(seed, "kb/Both.md", "page both sides delete\n")
|
||||||
|
_write(seed, "kb/ToDelete.md", "page the instance will delete\n")
|
||||||
|
_write(seed, "kb/RegularPage.md", "an ordinary page neither side has touched yet\n")
|
||||||
|
_write(seed, "raw/CONTRACT.md", "raw contract v1\n")
|
||||||
|
_write(seed, "work/CONTRACT.md", "work contract v1\n")
|
||||||
|
_write(seed, "reports/CONTRACT.md", "reports contract v1\n")
|
||||||
|
_write(seed, "tools/wikitool.py", "line one\nline two\nline three\n")
|
||||||
|
_commit(seed, "seed")
|
||||||
|
|
||||||
|
upstream = tmp_path / "upstream"
|
||||||
|
subprocess.run(["git", "clone", str(seed), str(upstream)], check=True, capture_output=True)
|
||||||
|
_git(upstream, "config", "user.name", "Upstream")
|
||||||
|
_git(upstream, "config", "user.email", "upstream@example.com")
|
||||||
|
|
||||||
|
# Cloned from `upstream`, not from `seed` directly: the remote (renamed
|
||||||
|
# below) must resolve to the path this fixture actually commits new
|
||||||
|
# upstream state into, or a later `git fetch upstream main` silently
|
||||||
|
# fetches from `seed` instead and never sees anything new.
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
subprocess.run(["git", "clone", str(upstream), str(repo)], check=True, capture_output=True)
|
||||||
|
_git(repo, "config", "user.name", "Test")
|
||||||
|
_git(repo, "config", "user.email", "test@example.com")
|
||||||
|
_git(repo, "remote", "rename", "origin", "upstream")
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", repo)
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
|
||||||
|
return upstream, repo
|
||||||
|
|
||||||
|
|
||||||
|
def _merge(**overrides):
|
||||||
|
kwargs = dict(remote="upstream", branch="main", no_fetch=False)
|
||||||
|
kwargs.update(overrides)
|
||||||
|
upstream_cmd.merge_command(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the four restbefund regressions, plus the baseline table from the issue ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_edit_of_a_page_the_instance_deleted_does_not_land(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_git(repo, "rm", "-q", "kb/ToDelete.md")
|
||||||
|
_commit(repo, "instance deletes ToDelete")
|
||||||
|
|
||||||
|
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
|
||||||
|
_commit(upstream, "upstream edits ToDelete")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "kb/ToDelete.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_new_page_does_not_land(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "kb/NewPage.md", "a demo page the upstream added\n")
|
||||||
|
_commit(upstream, "upstream adds NewPage")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "kb/NewPage.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_deleted_on_both_sides_is_a_noop(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_git(repo, "rm", "-q", "kb/Both.md")
|
||||||
|
_commit(repo, "instance deletes Both")
|
||||||
|
_git(upstream, "rm", "-q", "kb/Both.md")
|
||||||
|
_commit(upstream, "upstream deletes Both")
|
||||||
|
|
||||||
|
_merge() # must not raise
|
||||||
|
|
||||||
|
assert not (repo / "kb/Both.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_contract_change_lands(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conventions_template_change_lands_local_conventions_untouched(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
|
||||||
|
_commit(upstream, "upstream changes the conventions template")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
|
||||||
|
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_contract_change_does_not_land(two_repos):
|
||||||
|
"""A COLLECTION.md is instance-owned since #39 - one level deeper than
|
||||||
|
`<stage>/CONTRACT.md`, so `is_stack_owned` must say no to it."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(repo, "kb/entities/COLLECTION.md", "instance collection contract v2 (local)\n")
|
||||||
|
_commit(repo, "instance rewrites its own collection contract")
|
||||||
|
|
||||||
|
_write(upstream, "kb/entities/COLLECTION.md", "upstream collection contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes the default collection contract")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert (repo / "kb/entities/COLLECTION.md").read_text(encoding="utf-8") == (
|
||||||
|
"instance collection contract v2 (local)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_deletion_of_a_contract_file_lands(two_repos):
|
||||||
|
"""Restbefund 2: a machinery file the upstream deleted must not silently
|
||||||
|
survive because `git checkout MERGE_HEAD -- <path>` has nothing to check
|
||||||
|
out."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
|
||||||
|
_commit(upstream, "upstream drops raw/CONTRACT.md")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "raw/CONTRACT.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_stack_template_under_a_content_stage_lands(two_repos):
|
||||||
|
"""Restbefund 4: a brand-new stack-owned path the local tree has never
|
||||||
|
seen must still be recognised by the predicate, not by a literal list."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "kb/GLOSSARY.md.template", "a stack-owned template that never existed before\n")
|
||||||
|
_commit(upstream, "upstream adds a new template")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert (repo / "kb/GLOSSARY.md.template").read_text(encoding="utf-8") == (
|
||||||
|
"a stack-owned template that never existed before\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_workshop_run_files_do_not_land(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
|
||||||
|
_commit(upstream, "upstream ships an open work/ run")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "work/some-run").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_keeps_ignored_local_data_under_a_content_stage(two_repos):
|
||||||
|
"""`reports/` is gitignored except its contract, so a content stage's
|
||||||
|
working tree holds local data that is in no git tree and is not
|
||||||
|
recomputable - the telemetry traces `eval score` reads, saved eval
|
||||||
|
reports, past lint reports. Forcing the stage back to the local side must
|
||||||
|
not take those out as collateral: this instance had 497 trace directories
|
||||||
|
under reports/telemetry/ when the first version of this command wiped the
|
||||||
|
stage wholesale."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(repo, "reports/telemetry/session-a/trace.jsonl", '{"event": "local"}\n')
|
||||||
|
_write(repo, "reports/Lint Report 2026-09-04.md", "a local lint report\n")
|
||||||
|
assert _git(repo, "status", "--porcelain").stdout == "" # ignored, so the tree is clean
|
||||||
|
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert (repo / "reports/telemetry/session-a/trace.jsonl").read_text(encoding="utf-8") == (
|
||||||
|
'{"event": "local"}\n'
|
||||||
|
)
|
||||||
|
assert (repo / "reports/Lint Report 2026-09-04.md").exists()
|
||||||
|
assert (repo / "reports/CONTRACT.md").read_text(encoding="utf-8") == "reports contract v1\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_content_under_a_stage_absent_from_head_does_not_land(two_repos):
|
||||||
|
"""The stage guard must not rest on the local side happening to track
|
||||||
|
something under that stage: an instance whose `work/` holds no tracked
|
||||||
|
file at all must still not receive the upstream's open run."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_git(repo, "rm", "-q", "work/CONTRACT.md")
|
||||||
|
_commit(repo, "instance has nothing tracked under work/")
|
||||||
|
|
||||||
|
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
|
||||||
|
_commit(upstream, "upstream ships an open work/ run")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "work/some-run").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_upstream_commit_mixing_every_case_at_once(two_repos):
|
||||||
|
"""The acceptance test from the issue: a single upstream commit that edits
|
||||||
|
a page the instance deleted, adds a new page, deletes an untouched page,
|
||||||
|
changes a stack contract, changes a template, and deletes a different
|
||||||
|
stack contract - all at once, all restored or discarded correctly by one
|
||||||
|
`upstream merge` call."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_git(repo, "rm", "-q", "kb/ToDelete.md")
|
||||||
|
_commit(repo, "instance deletes ToDelete")
|
||||||
|
|
||||||
|
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
|
||||||
|
_write(upstream, "kb/BrandNewPage.md", "a demo page the upstream added\n")
|
||||||
|
_git(upstream, "rm", "-q", "kb/RegularPage.md")
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
|
||||||
|
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
|
||||||
|
_commit(upstream, "one upstream commit: edit + add + delete + contract + template + contract-delete")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
assert not (repo / "kb/ToDelete.md").exists()
|
||||||
|
assert not (repo / "kb/BrandNewPage.md").exists()
|
||||||
|
assert (repo / "kb/RegularPage.md").exists()
|
||||||
|
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
|
||||||
|
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
|
||||||
|
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
|
||||||
|
assert not (repo / "raw/CONTRACT.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_conflict_in_tools_leaves_the_merge_open(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
|
||||||
|
_write(repo, "tools/wikitool.py", "line one\nLOCAL CHANGE\nline three\n")
|
||||||
|
_commit(repo, "local edits tools/wikitool.py")
|
||||||
|
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
_write(upstream, "tools/wikitool.py", "line one\nUPSTREAM CHANGE\nline three\n")
|
||||||
|
_commit(upstream, "upstream edits the same line")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_merge()
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||||
|
assert (repo / ".git" / "MERGE_HEAD").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_merge_git_refuses_to_open_deletes_nothing(two_repos, tmp_path):
|
||||||
|
"""The failure mode with the worst blast radius if it is not guarded:
|
||||||
|
without MERGE_HEAD, every stack-owned path in HEAD reads as "the upstream
|
||||||
|
deleted it", and the restore loop would remove kb/CONTRACT.md,
|
||||||
|
raw/CONTRACT.md and every template. A merge git refuses to start must stop
|
||||||
|
before that, with the tree untouched."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
unrelated = tmp_path / "unrelated"
|
||||||
|
unrelated.mkdir()
|
||||||
|
_git(unrelated, "init", "-b", "main")
|
||||||
|
_git(unrelated, "config", "user.name", "Unrelated")
|
||||||
|
_git(unrelated, "config", "user.email", "unrelated@example.com")
|
||||||
|
_write(unrelated, "somefile.md", "no shared history with the instance\n")
|
||||||
|
_commit(unrelated, "unrelated root commit")
|
||||||
|
|
||||||
|
_git(repo, "remote", "set-url", "upstream", str(unrelated))
|
||||||
|
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_merge()
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||||
|
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v1\n"
|
||||||
|
assert (repo / "raw/CONTRACT.md").exists()
|
||||||
|
assert (repo / "kb/CONVENTIONS.md.template").exists()
|
||||||
|
assert _git(repo, "status", "--porcelain").stdout == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_message_reports_what_changed_not_what_was_restored(two_repos, capsys):
|
||||||
|
"""Restoring every stack-owned path from MERGE_HEAD touches all of them
|
||||||
|
whether or not the upstream moved any, so the report has to ask git what
|
||||||
|
changed - otherwise a one-file update is announced as five."""
|
||||||
|
upstream, repo = two_repos
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes exactly one stack path")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Stack paths changed (1)" in out
|
||||||
|
assert "kb/CONTRACT.md" in out
|
||||||
|
assert "kb/CONVENTIONS.md.template" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_working_tree_is_refused_untouched(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
(repo / "kb/CONTRACT.md").write_text("uncommitted local edit\n", encoding="utf-8")
|
||||||
|
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_merge()
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||||
|
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "uncommitted local edit\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_up_to_date_is_a_noop(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
_merge() # nothing new upstream at all
|
||||||
|
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_warns_when_the_publish_remote_gate_is_unarmed(two_repos, capsys):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
assert git_publish.read_allowed_push_urls() is None # no .wikitool-remotes.json in this repo
|
||||||
|
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "WARN" in captured.out
|
||||||
|
assert ".wikitool-remotes.json" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_stays_silent_when_the_publish_remote_gate_is_armed(two_repos, capsys):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||||
|
'{"schema": 1, "allowed_push_urls": ["ssh://example/test.git"]}\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||||
|
|
||||||
|
_merge()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "WARN" not in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_dist_cmd_contract_only_stages_agree_with_ownership(two_repos):
|
||||||
|
"""Consistency guard for the ownership refactor: `dist_cmd`'s own list of
|
||||||
|
stage-contract paths and `ownership.is_stack_owned` must not be able to
|
||||||
|
name a different set of stages - both are sourced from
|
||||||
|
`ownership.CONTENT_STAGES` now, so a stage added to one and not the other
|
||||||
|
fails this rather than only surfacing in a real merge."""
|
||||||
|
from chemenu.commands import dist_cmd
|
||||||
|
|
||||||
|
assert dist_cmd.CONTRACT_ONLY_STAGES # sanity: the derivation still yields entries
|
||||||
|
for relative in dist_cmd.CONTRACT_ONLY_STAGES:
|
||||||
|
assert ownership.is_stack_owned(relative)
|
||||||
|
|
||||||
|
|
||||||
|
# --- upstream verify --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_is_clean_on_a_stack_owned_only_change(two_repos):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
_write(repo, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||||
|
_commit(repo, "advance kb/CONTRACT.md")
|
||||||
|
|
||||||
|
upstream_cmd.verify_command(since=since, until="HEAD") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_fails_on_a_hand_botched_merge(two_repos, capsys):
|
||||||
|
upstream, repo = two_repos
|
||||||
|
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
_write(repo, "kb/SneakedIn.md", "content that arrived outside a stack-owned path\n")
|
||||||
|
_commit(repo, "a hand-resolved merge that let content through")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
upstream_cmd.verify_command(since=since, until="HEAD")
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "kb/SneakedIn.md" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
# --- ownership predicate, exercised directly ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"relative,expected",
|
||||||
|
[
|
||||||
|
("kb/CONTRACT.md", True),
|
||||||
|
("raw/CONTRACT.md", True),
|
||||||
|
("work/CONTRACT.md", True),
|
||||||
|
("reports/CONTRACT.md", True),
|
||||||
|
("kb/CONVENTIONS.md.template", True),
|
||||||
|
("kb/entities/COLLECTION.md.template", True),
|
||||||
|
("kb/GLOSSARY.md.template", True),
|
||||||
|
("kb/CONVENTIONS.md", False),
|
||||||
|
("kb/entities/COLLECTION.md", False),
|
||||||
|
("kb/concepts/Some Page.md", False),
|
||||||
|
("raw/notes/x.md", False),
|
||||||
|
("tools/CONTRACT.md", False), # not a content stage
|
||||||
|
("kb/log.md", False), # export stub, not stack-owned
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_stack_owned(relative, expected):
|
||||||
|
assert ownership.is_stack_owned(relative) == expected
|
||||||
@@ -83,6 +83,86 @@ def test_compare_separates_a_compatible_update_from_a_migration(local, latest, s
|
|||||||
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
||||||
|
|
||||||
|
|
||||||
|
# --- candidates: parsing and ordering ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
|
||||||
|
def test_parse_accepts_a_candidate_suffix(text):
|
||||||
|
version = Version.parse(text)
|
||||||
|
assert version.is_prerelease
|
||||||
|
assert version.beta == int(text.rsplit(".", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_release_has_no_beta():
|
||||||
|
version = Version.parse("4.4.0")
|
||||||
|
assert not version.is_prerelease
|
||||||
|
assert version.beta is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"lesser,greater",
|
||||||
|
[
|
||||||
|
("4.4.0-beta.1", "4.4.0"),
|
||||||
|
("4.4.0-beta.1", "4.4.0-beta.2"),
|
||||||
|
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
|
||||||
|
("4.4.0-beta.9", "4.4.1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
|
||||||
|
assert Version.parse(lesser) < Version.parse(greater)
|
||||||
|
assert Version.parse(greater) > Version.parse(lesser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_strips_the_candidate_suffix():
|
||||||
|
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
|
||||||
|
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumped_always_returns_a_release_even_from_a_candidate():
|
||||||
|
"""`bumped()` answers "what would the next fixed version be" - it is
|
||||||
|
`escalate()` that knows about running candidates."""
|
||||||
|
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
|
||||||
|
|
||||||
|
|
||||||
|
# --- candidates: escalation --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_opens_the_first_candidate_at_beta_one():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
candidate = version_mod.escalate(release, release, "minor")
|
||||||
|
assert str(candidate) == "4.4.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
first = version_mod.escalate(release, release, "minor")
|
||||||
|
second = version_mod.escalate(release, first, "patch")
|
||||||
|
assert str(second) == "4.4.0-beta.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_never_steps_back_down():
|
||||||
|
release = Version.parse("1.4.0")
|
||||||
|
major = version_mod.escalate(release, release, "major")
|
||||||
|
still_major = version_mod.escalate(release, major, "patch")
|
||||||
|
assert still_major.base == major.base
|
||||||
|
assert still_major.beta == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_raises_the_base_and_resets_the_bump_count():
|
||||||
|
release = Version.parse("4.3.3")
|
||||||
|
minor = version_mod.escalate(release, release, "minor")
|
||||||
|
major = version_mod.escalate(release, minor, "major")
|
||||||
|
assert str(major) == "5.0.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
|
||||||
|
"""The fresh-distribution edge case: a changelog with no versioned entry at
|
||||||
|
all opens a candidate straight from `current`, rather than failing."""
|
||||||
|
fresh = Version.parse("0.1.0")
|
||||||
|
candidate = version_mod.escalate(None, fresh, "patch")
|
||||||
|
assert str(candidate) == "0.1.1-beta.1"
|
||||||
|
|
||||||
|
|
||||||
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
||||||
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
||||||
assert "migration" in status.headline.lower()
|
assert "migration" in status.headline.lower()
|
||||||
@@ -167,14 +247,75 @@ def test_changes_section_is_none_for_an_undocumented_version():
|
|||||||
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
||||||
|
|
||||||
|
|
||||||
def test_insert_changes_entry_lands_above_the_newest_entry():
|
def test_last_release_skips_an_open_candidate_above_it():
|
||||||
|
text = (
|
||||||
|
CHANGES_HEADER
|
||||||
|
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
|
||||||
|
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
)
|
||||||
|
assert version_mod.last_release(text) == Version(0, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_release_is_none_with_no_versioned_entry_at_all():
|
||||||
|
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
|
||||||
|
assert version_mod.last_release(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
|
||||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
result = version_mod.insert_changes_entry(
|
result = version_mod.insert_changes_entry(
|
||||||
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
|
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
|
||||||
)
|
)
|
||||||
assert result.index("## 0.2.0") < result.index("## 0.1.0")
|
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
|
||||||
assert "Preamble." in result
|
assert "Preamble." in result
|
||||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
|
assert "- Newer" in result # the bump-title list seeds itself with this title
|
||||||
|
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_updates_an_open_candidate_in_place():
|
||||||
|
"""The second bump of the same candidate must not open a second entry -
|
||||||
|
one entry per running candidate, per instructions/dev/version-parts.md."""
|
||||||
|
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
first = version_mod.insert_changes_entry(
|
||||||
|
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
|
||||||
|
)
|
||||||
|
second = version_mod.insert_changes_entry(
|
||||||
|
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
|
||||||
|
)
|
||||||
|
assert second.count("## 0.2.0") == 1
|
||||||
|
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
|
||||||
|
assert "- First title" in second
|
||||||
|
assert "- Second title" in second
|
||||||
|
assert "## 0.1.0" in second # the older, already-released entry survives untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
|
||||||
|
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||||
|
first = version_mod.insert_changes_entry(
|
||||||
|
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
|
||||||
|
breaking_reason="the feed moved", no_migration_reason="kb untouched",
|
||||||
|
)
|
||||||
|
second = version_mod.insert_changes_entry(
|
||||||
|
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
|
||||||
|
)
|
||||||
|
assert version_mod.BREAKING_CHANGE_MARKER in second
|
||||||
|
assert "the feed moved" in second
|
||||||
|
assert version_mod.MIGRATION_NONE_MARKER in second
|
||||||
|
assert "kb untouched" in second
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
|
||||||
|
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
|
||||||
|
released = version_mod.release_entry(text, "2026-09-05")
|
||||||
|
assert "## 0.2.0 - 2026-09-05 - Second title" in released
|
||||||
|
assert "- First title" in released
|
||||||
|
assert "- Second title" in released
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_entry_can_replace_the_title():
|
||||||
|
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
|
||||||
|
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
|
||||||
|
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||||
|
|
||||||
|
|
||||||
# --- version bump ----------------------------------------------------------
|
# --- version bump ----------------------------------------------------------
|
||||||
@@ -185,13 +326,28 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
|||||||
major=False, minor=True, patch=False, title="Something happened",
|
major=False, minor=True, patch=False, title="Something happened",
|
||||||
breaking=None, no_migration=None, dry_run=False,
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert "## 1.1.0 - " in changes
|
assert "## 1.1.0-beta.1 - " in changes
|
||||||
assert "Something happened" in changes
|
assert "Something happened" in changes
|
||||||
assert "**Author:** Test Author" in changes
|
assert "**Author:** Test Author" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert changes.count("## 1.1.0") == 1
|
||||||
|
assert "First" in changes and "Second" in changes
|
||||||
|
|
||||||
|
|
||||||
def test_bump_dry_run_writes_nothing(tree):
|
def test_bump_dry_run_writes_nothing(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
||||||
@@ -219,9 +375,10 @@ def test_bump_refuses_an_empty_title(tree):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
||||||
"""A changelog documenting a version the tree has not reached means
|
"""A changelog whose newest entry names a different version than VERSION
|
||||||
someone edited one of the two by hand; bumping past it would hide that."""
|
means someone edited one of the two by hand; bumping past it would hide
|
||||||
|
that instead of surfacing it."""
|
||||||
(tree / "CHANGES.md").write_text(
|
(tree / "CHANGES.md").write_text(
|
||||||
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -258,7 +415,31 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
|||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
|
||||||
|
"""Finding #4: the requirement fires once, at the bump that first escalates
|
||||||
|
to the boundary; a later bump of the same candidate is not asked again."""
|
||||||
|
migrations = tree / "instructions" / "migrations"
|
||||||
|
(migrations / "2.0.0-retype.md").write_text(
|
||||||
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||||
|
"description: Retype every page.\nmanual: true\n"
|
||||||
|
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
|
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Follow-up",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||||
|
assert "every page is retyped" in changes
|
||||||
|
|
||||||
|
|
||||||
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||||
@@ -321,6 +502,65 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
|||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- version release --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert "## 1.1.0 - " in changes
|
||||||
|
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
|
||||||
|
assert "First bump" in changes # kept, since --title was not given
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_can_replace_the_title(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert "## 1.1.0 - " in changes
|
||||||
|
assert "Summary of both bumps" in changes
|
||||||
|
# the machine-managed bump list is left as the record of what happened
|
||||||
|
assert "First bump" in changes
|
||||||
|
assert "Second bump" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_dry_run_writes_nothing(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.release_command(title=None, dry_run=True)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_when_version_is_already_a_release(tree):
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
# --- version notes ---------------------------------------------------------
|
# --- version notes ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
|||||||
assert "## 1.0.0" in capsys.readouterr().out
|
assert "## 1.0.0" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.notes_command(version=None)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "## 1.1.0-beta.2" in out
|
||||||
|
assert "First bump" in out and "Second bump" in out
|
||||||
|
|
||||||
|
|
||||||
def test_notes_fails_for_a_version_with_no_entry(tree):
|
def test_notes_fails_for_a_version_with_no_entry(tree):
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.notes_command(version="9.9.9")
|
version_cmd.notes_command(version="9.9.9")
|
||||||
|
|||||||
+252
-19
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -42,7 +43,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import blocks, config
|
||||||
|
|
||||||
VERSION_FILENAME = "VERSION"
|
VERSION_FILENAME = "VERSION"
|
||||||
CHANGES_FILENAME = "CHANGES.md"
|
CHANGES_FILENAME = "CHANGES.md"
|
||||||
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
|||||||
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
||||||
|
|
||||||
PARTS = ("major", "minor", "patch")
|
PARTS = ("major", "minor", "patch")
|
||||||
|
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
|
||||||
|
|
||||||
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
|
||||||
# second ordering rule everywhere a version is compared - the release feed, the
|
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
|
||||||
# migration chain, the compatibility check - to serve a workflow this stack does
|
# ordering numeric and total. See "Candidates and releases" below.
|
||||||
# not have.
|
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
|
||||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
|
||||||
|
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
|
||||||
|
# every `--title` a candidate has collected across its bumps. Reuses
|
||||||
|
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
|
||||||
|
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
|
||||||
|
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
|
||||||
|
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
|
||||||
|
# its rendering, belong here instead.
|
||||||
|
BUMPS_BLOCK_NAME = "bumps"
|
||||||
|
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
||||||
|
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
|
||||||
|
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
|
||||||
|
|
||||||
# Written into a CHANGES.md entry whose version crosses a compatibility
|
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||||
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
|||||||
# A changelog entry that names a version. Entries predating versioning start
|
# A changelog entry that names a version. Entries predating versioning start
|
||||||
# with a date instead and are deliberately not matched - they are history, not
|
# with a date instead and are deliberately not matched - they are history, not
|
||||||
# a claim about which version the tree is.
|
# a claim about which version the tree is.
|
||||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
class VersionError(ValueError):
|
class VersionError(ValueError):
|
||||||
@@ -92,23 +105,67 @@ class VersionError(ValueError):
|
|||||||
written to be shown to the user verbatim."""
|
written to be shown to the user verbatim."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, order=True)
|
@functools.total_ordering
|
||||||
|
@dataclass(frozen=True)
|
||||||
class Version:
|
class Version:
|
||||||
|
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
|
||||||
|
(`-beta.N`) between two releases.
|
||||||
|
|
||||||
|
**Candidates and releases.** Between two releases the stack carries at
|
||||||
|
most one running candidate rather than a fresh number per `bump` - see
|
||||||
|
`instructions/dev/version-parts.md`. `VERSION` holds either a release
|
||||||
|
(`beta is None`) or a candidate (`beta` is the bump count since the
|
||||||
|
candidate's base was last raised). `base` strips the suffix; `bumped()`
|
||||||
|
always returns a release-shaped `Version`, because it answers "what would
|
||||||
|
the *next fixed* version be", never "what candidate comes next" - that
|
||||||
|
answer needs `escalate()`, which also knows the last release to escalate
|
||||||
|
against.
|
||||||
|
|
||||||
|
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
|
||||||
|
a real release after every candidate that shares its base - `4.4.0-beta.1
|
||||||
|
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
|
||||||
|
`int` do not compare, and the ordering is inverted relative to field
|
||||||
|
declaration order anyway. `functools.total_ordering` plus an explicit
|
||||||
|
`__lt__` is the direct way to say what the ordering actually is.
|
||||||
|
"""
|
||||||
|
|
||||||
major: int
|
major: int
|
||||||
minor: int
|
minor: int
|
||||||
patch: int
|
patch: int
|
||||||
|
beta: Optional[int] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, text: str) -> "Version":
|
def parse(cls, text: str) -> "Version":
|
||||||
match = _SEMVER_RE.match(text or "")
|
match = _SEMVER_RE.match(text or "")
|
||||||
if not match:
|
if not match:
|
||||||
raise VersionError(
|
raise VersionError(
|
||||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH"
|
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
||||||
|
"or MAJOR.MINOR.PATCH-beta.N"
|
||||||
)
|
)
|
||||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
beta = int(match.group(4)) if match.group(4) is not None else None
|
||||||
|
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
|
||||||
|
|
||||||
def __str__(self) -> str: # noqa: D105 - obvious
|
def __str__(self) -> str: # noqa: D105 - obvious
|
||||||
return f"{self.major}.{self.minor}.{self.patch}"
|
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
|
||||||
|
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
|
||||||
|
|
||||||
|
def _sort_key(self) -> tuple[int, int, int, int, int]:
|
||||||
|
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
|
||||||
|
|
||||||
|
def __lt__(self, other: "Version") -> bool:
|
||||||
|
if not isinstance(other, Version):
|
||||||
|
return NotImplemented
|
||||||
|
return self._sort_key() < other._sort_key()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prerelease(self) -> bool:
|
||||||
|
return self.beta is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base(self) -> "Version":
|
||||||
|
"""This version with any candidate suffix stripped - what it would be
|
||||||
|
once fixed. A no-op on a version that is already a release."""
|
||||||
|
return Version(self.major, self.minor, self.patch)
|
||||||
|
|
||||||
def bumped(self, part: str) -> "Version":
|
def bumped(self, part: str) -> "Version":
|
||||||
if part == "major":
|
if part == "major":
|
||||||
@@ -127,6 +184,10 @@ class Version:
|
|||||||
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
||||||
non-zero component, so it compares by all three - during `0.0.x`
|
non-zero component, so it compares by all three - during `0.0.x`
|
||||||
every release is a breaking one, which is what that range means.
|
every release is a breaking one, which is what that range means.
|
||||||
|
|
||||||
|
Computed over major/minor/patch alone, i.e. over the **base**: a
|
||||||
|
candidate's pre-release suffix carries no compatibility information of
|
||||||
|
its own, it is the base that will be released that does.
|
||||||
"""
|
"""
|
||||||
components = (self.major, self.minor, self.patch)
|
components = (self.major, self.minor, self.patch)
|
||||||
for index, component in enumerate(components):
|
for index, component in enumerate(components):
|
||||||
@@ -135,6 +196,44 @@ class Version:
|
|||||||
return components
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_between(reference: Version, base: Version) -> Optional[str]:
|
||||||
|
"""Which part `base` has escalated past `reference` on, or None if equal.
|
||||||
|
|
||||||
|
Both are release-shaped (no beta): `reference` is the last real release,
|
||||||
|
`base` is a candidate's base. Exactly one of major/minor/patch differs,
|
||||||
|
because `bumped()` always resets everything to the right of the part it
|
||||||
|
raises - so the leftmost differing component *is* the stage.
|
||||||
|
"""
|
||||||
|
for part in PARTS:
|
||||||
|
if getattr(reference, part) != getattr(base, part):
|
||||||
|
return part
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
|
||||||
|
"""The next candidate: `current` escalated by `part` against `last_release`,
|
||||||
|
max-wins.
|
||||||
|
|
||||||
|
A running candidate never steps back down: bumping `--patch` on a MINOR
|
||||||
|
candidate only advances its bump count (`beta`), it does not lower the
|
||||||
|
base. `last_release=None` is the fresh-distribution edge case - a
|
||||||
|
changelog with no versioned entry at all - where there is nothing to
|
||||||
|
escalate against, so the candidate's base is simply `current` bumped by
|
||||||
|
`part`; see instructions/dev/version-parts.md for why that is not an
|
||||||
|
error.
|
||||||
|
"""
|
||||||
|
if part not in _STAGE_RANK:
|
||||||
|
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
||||||
|
reference = last_release if last_release is not None else (
|
||||||
|
current.base if current.is_prerelease else current
|
||||||
|
)
|
||||||
|
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
|
||||||
|
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
|
||||||
|
new_base = reference.bumped(new_stage)
|
||||||
|
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
|
||||||
|
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UpdateStatus:
|
class UpdateStatus:
|
||||||
"""The answer `version check` reports. `state` is the actionable part:
|
"""The answer `version check` reports. `state` is the actionable part:
|
||||||
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
|
|||||||
return Version.parse(match.group(1))
|
return Version.parse(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def last_release(text: str) -> Optional[Version]:
|
||||||
|
"""The newest entry that is a **release**, not a running candidate, or
|
||||||
|
`None` if the changelog names no release at all yet.
|
||||||
|
|
||||||
|
Entries are inserted newest-first (see `insert_changes_entry`), so the
|
||||||
|
first non-pre-release heading found scanning top-down is the last release
|
||||||
|
- whether or not the very top entry is an open candidate sitting above it.
|
||||||
|
A changelog with no versioned entry (a fresh distribution) answers `None`,
|
||||||
|
which `escalate()` treats as its own edge case rather than an error.
|
||||||
|
"""
|
||||||
|
for match in _CHANGES_ENTRY_RE.finditer(text):
|
||||||
|
version = Version.parse(match.group(1))
|
||||||
|
if not version.is_prerelease:
|
||||||
|
return version
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def changes_section(text: str, version: Version) -> Optional[str]:
|
def changes_section(text: str, version: Version) -> Optional[str]:
|
||||||
"""The body of one version's entry, heading included, ready to become
|
"""The body of one version's entry, heading included, ready to become
|
||||||
release notes.
|
release notes.
|
||||||
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bumps_block(titles: list[str]) -> str:
|
||||||
|
lines = "\n".join(f"- {title}" for title in titles)
|
||||||
|
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_titles(section: str) -> list[str]:
|
||||||
|
match = _BUMPS_RE.search(section)
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
line[2:].strip()
|
||||||
|
for line in match.group(1).strip("\n").splitlines()
|
||||||
|
if line.strip().startswith("- ")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||||
|
if pattern.search(section):
|
||||||
|
return pattern.sub(line, section, count=1)
|
||||||
|
anchor = section.find(_BUMPS_CLOSE)
|
||||||
|
if anchor != -1:
|
||||||
|
insert_at = section.find("\n", anchor)
|
||||||
|
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
||||||
|
else:
|
||||||
|
insert_at = len(section)
|
||||||
|
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_span(text: str) -> tuple[int, int]:
|
||||||
|
"""Start/end offsets of the topmost entry, heading included."""
|
||||||
|
match = re.search(r"^## ", text, re.MULTILINE)
|
||||||
|
if not match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
|
||||||
|
start = match.start()
|
||||||
|
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
|
||||||
|
end = start + 1 + following.start() if following else len(text)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _update_open_candidate(
|
||||||
|
text: str,
|
||||||
|
version: Version,
|
||||||
|
date: str,
|
||||||
|
title: str,
|
||||||
|
breaking_reason: Optional[str],
|
||||||
|
no_migration_reason: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||||
|
`title` to its machine-managed bump list, and set the breaking/no-migration
|
||||||
|
lines only where this call supplies them - see `insert_changes_entry`."""
|
||||||
|
start, end = _entry_span(text)
|
||||||
|
section = text[start:end]
|
||||||
|
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
if not heading_match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
|
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||||
|
|
||||||
|
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
||||||
|
|
||||||
|
if breaking_reason:
|
||||||
|
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||||
|
if no_migration_reason:
|
||||||
|
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||||
|
|
||||||
|
return text[:start] + section + text[end:]
|
||||||
|
|
||||||
|
|
||||||
def insert_changes_entry(
|
def insert_changes_entry(
|
||||||
text: str,
|
text: str,
|
||||||
version: Version,
|
version: Version,
|
||||||
@@ -351,18 +542,35 @@ def insert_changes_entry(
|
|||||||
no_migration_reason: Optional[str] = None,
|
no_migration_reason: Optional[str] = None,
|
||||||
breaking_reason: Optional[str] = None,
|
breaking_reason: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add a heading for `version` above the newest existing entry.
|
"""Open a new entry above the newest existing one, or - when the topmost
|
||||||
|
entry is still an open candidate (a pre-release heading) - update that
|
||||||
|
entry in place instead.
|
||||||
|
|
||||||
Only the skeleton: heading, date, author, and - when a compatibility
|
`version bump` always lands on a candidate (see `escalate`); only
|
||||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
`version release` fixes one, and it edits the heading directly rather than
|
||||||
content has to change where that applies. The entry's actual content is
|
through this path (`version_cmd.release_command`), which is what makes "is
|
||||||
written afterwards by whoever made the change, which is also why `bump`
|
the topmost heading still a pre-release" the right test for "is a
|
||||||
refuses to invent a title.
|
candidate still open" here.
|
||||||
|
|
||||||
The break comes first: it is what an operator reading the release notes has
|
A fresh entry gets the skeleton only: heading, date, author, the
|
||||||
to act on, and the migration line only qualifies it.
|
machine-managed bump-title list (started with this one title, for a
|
||||||
|
candidate), and - when a compatibility boundary is crossed - the line
|
||||||
|
saying what breaks, plus the line saying no content has to change where
|
||||||
|
that applies. The break comes first: it is what an operator reading the
|
||||||
|
release notes has to act on, and the migration line only qualifies it. The
|
||||||
|
entry's actual prose is written afterwards by whoever made the change,
|
||||||
|
which is also why `bump` refuses to invent a title.
|
||||||
"""
|
"""
|
||||||
|
top = top_changes_version(text)
|
||||||
|
if top is not None and top.is_prerelease:
|
||||||
|
return _update_open_candidate(
|
||||||
|
text, version, date, title,
|
||||||
|
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||||
|
)
|
||||||
|
|
||||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||||
|
if version.is_prerelease:
|
||||||
|
lines += [_bumps_block([title]), ""]
|
||||||
if breaking_reason:
|
if breaking_reason:
|
||||||
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
||||||
if no_migration_reason:
|
if no_migration_reason:
|
||||||
@@ -372,3 +580,28 @@ def insert_changes_entry(
|
|||||||
if anchor:
|
if anchor:
|
||||||
return text[: anchor.start()] + entry + text[anchor.start():]
|
return text[: anchor.start()] + entry + text[anchor.start():]
|
||||||
return text.rstrip() + "\n\n---\n\n" + entry
|
return text.rstrip() + "\n\n---\n\n" + entry
|
||||||
|
|
||||||
|
|
||||||
|
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
|
||||||
|
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
|
||||||
|
today's heading, keeping the previous title unless `title` overrides it.
|
||||||
|
|
||||||
|
Leaves the rest of the entry - the bump-title list included - untouched:
|
||||||
|
it is the record of what happened across the candidate's life, and a
|
||||||
|
release call has no reason to discard it. `version_cmd.release_command`
|
||||||
|
is the only caller; it has already checked the topmost entry names a
|
||||||
|
pre-release, so a non-pre-release version reaching here is a caller bug.
|
||||||
|
"""
|
||||||
|
start, end = _entry_span(text)
|
||||||
|
section = text[start:end]
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
if not heading_match:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
|
|
||||||
|
current = Version.parse(heading_match.group(1))
|
||||||
|
rest = heading_match.group(2) or ""
|
||||||
|
_, _, existing_title = rest.partition(" - ")
|
||||||
|
new_title = title if title is not None else existing_title
|
||||||
|
|
||||||
|
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
|
||||||
|
return text[:start] + section + text[end:]
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ name: comparison
|
|||||||
description: Strukturierter Typ für Vergleichsseiten, die mehrere Entities oder Ansätze gegenüberstellen
|
description: Strukturierter Typ für Vergleichsseiten, die mehrere Entities oder Ansätze gegenüberstellen
|
||||||
schema: types/comparison.schema.yaml
|
schema: types/comparison.schema.yaml
|
||||||
base_dir: comparisons
|
base_dir: comparisons
|
||||||
page_ref_fields: [entities]
|
page_ref_fields: [entities, related]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Comparison
|
# Comparison
|
||||||
@@ -32,6 +32,7 @@ page_ref_fields: [entities]
|
|||||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
||||||
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
||||||
| `entities` | Ja | Titel der verglichenen Entities |
|
| `entities` | Ja | Titel der verglichenen Entities |
|
||||||
|
| `related` | Nein | Deklarierte ausgehende Kanten - je Subjekt eine `compares-with`-Kante, geschrieben von `wikitool xref add` |
|
||||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
||||||
|
|
||||||
## Autorenanweisungen
|
## Autorenanweisungen
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ properties:
|
|||||||
type: string
|
type: string
|
||||||
description: Entity titles being compared
|
description: Entity titles being compared
|
||||||
minItems: 2
|
minItems: 2
|
||||||
|
related:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: object
|
||||||
|
minProperties: 1
|
||||||
|
maxProperties: 1
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Declared outbound edges, in the same shape entity and concept pages use.
|
||||||
|
A comparison's own assertion is `compares-with` against each subject: the
|
||||||
|
titles are already in `entities:`, but that field is the untyped
|
||||||
|
provenance-style list, so without this one the edge the page exists to
|
||||||
|
make would live only in hand-written prose - which is the thing
|
||||||
|
instructions/link-taxonomy.md was built to end.
|
||||||
summary:
|
summary:
|
||||||
type: string
|
type: string
|
||||||
description: 1-line summary for index.md
|
description: 1-line summary for index.md
|
||||||
|
|||||||
Reference in New Issue
Block a user