• v4.5.0 0ba94c63d1

    v4.5.0
    CI / verify (push) Successful in 53s
    Release / release (push) Successful in 37s
    Stable

    torben released this 2026-09-04 10:51:12 +00:00 | 65 commits to main since this release

    4.5.0 - 2026-09-04 - Beide Update-Wege in Code: upstream merge fuer Clones, dist upgrade fuer Tarball-Instanzen

    Author: Torben Nehmer

    • 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

    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 merges 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 upgrades eigenen Vergleich.

    Quelle ist immer ein bereits vorhandenes Verzeichnis oder .tar.gz - kein Download, das bleibt
    allein version checks 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.

    Downloads