raw accept: Stem-Eindeutigkeit im Typverzeichnis erzwingen, --replaces als einziger Weg daran vorbei (schliesst #64)
CI / verify (push) Successful in 55s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/tests/test_raw_cmd.py
This commit is contained in:
2026-09-05 08:51:41 +02:00
parent 36d2128f29
commit 0b3c496fff
7 changed files with 454 additions and 7 deletions
+43 -1
View File
@@ -35,7 +35,7 @@ dev-checkout concern - readable here, never shipped as something to parse.
--- ---
## 4.8.0-beta.3 - 2026-09-05 - raw accept: incoming/ als abgeleiteter Rohablage-Eingang (schliesst #58) ## 4.8.0-beta.4 - 2026-09-05 - raw accept: Stem-Eindeutigkeit im Typverzeichnis erzwingen, --replaces als einziger Weg daran vorbei (schliesst #64)
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -44,6 +44,7 @@ dev-checkout concern - readable here, never shipped as something to parse.
- page move: eine kb-Seite folgt ihrem Subtype ins Verzeichnis, das ihr Type-Spec berechnet - page move: eine kb-Seite folgt ihrem Subtype ins Verzeichnis, das ihr Type-Spec berechnet
- kb/CONTRACT.md: Tiefe 1 als Grenze - Katalog liest nur eine Area-Ebene - kb/CONTRACT.md: Tiefe 1 als Grenze - Katalog liest nur eine Area-Ebene
- raw accept: incoming/ als abgeleiteter Rohablage-Eingang (schliesst #58) - raw accept: incoming/ als abgeleiteter Rohablage-Eingang (schliesst #58)
- raw accept: Stem-Eindeutigkeit im Typverzeichnis erzwingen, --replaces als einziger Weg daran vorbei (schliesst #64)
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
Das Label `status/incoming` gibt es seit heute in Gitea: der Mensch legt einen Das Label `status/incoming` gibt es seit heute in Gitea: der Mensch legt einen
@@ -258,6 +259,47 @@ unangetastet, keine fremde Instanz muss migrieren, vorwärts wie rückwärts rei
Schließt #58. Schließt #58.
`raw accept` prüfte Kollisionen bisher nur auf einzelnen Dateipfaden
(`dst.exists()`), nie auf dem Bundle-Verzeichnis selbst. Weil der Bundle-Name
aus dem Stem der Primärdatei entsteht, konnte eine zweite, unabhängige Quelle
wortlos in das Bundle einer ersten wandern, sobald die Dateinamen zufällig
nicht kollidierten — verifiziert mit einem Wegwerf-Test: `handbuch.txt` +
`anhang.md` ohne `--page` landeten unbemerkt in einem bestehenden
`raw/documents/handbuch/`. `lint` meldete nichts, weil beide Quellen ihre
Dateien korrekt abdeckten.
Die Menge der Namen auf `raw/<typ>/`-Ebene — Dateistämme plus
Bundle-Verzeichnisnamen — ist jetzt eindeutig erzwungen (`_occupied_stems` in
`raw_cmd.py`), unter Ausnahme dessen, was der Aufruf selbst schon besitzt: ein
Bundle, das über `--page` wächst, oder ein bereits registriertes, sich
fortsetzendes Bundle. Ein belegter Stem wird mit Exit 1 abgelehnt und nennt
beide Auswege, ohne einen zu empfehlen — `--replaces` oder Umbenennen in
`incoming/`.
`--replaces <raw-pfad>` ist der einzige sanktionierte Weg, eine Rohdatei
wortwörtlich zu ersetzen: genau eine eingehende Datei, identischer Dateiname,
gleiches Typverzeichnis, Ablehnung bei mehr als einem Owner. `raw_files:`
bleibt unverändert, es wird keine `kb/`-Seite geschrieben, und die Altfassung
lebt ausschließlich in `git log --follow` weiter — kein Archivverzeichnis,
kein Hash im Dateinamen, kein neues Frontmatter-Feld. Nach einem Ersatz nennt
das Kommando die Source-Seite und ihre zitierenden Seiten, damit deren
Nachzug im selben Commit passiert wie die Ersetzung.
`raw/CONTRACT.md` § Rules trägt beide Regeln aus dieser Entscheidung
(unveränderlich, aber ersetzbar als Ganzes); `instructions/wiki-ingest/SKILL.md`
benennt den Kollisionsfall als Haltepunkt, an dem eine Sitzung die Meldung dem
Menschen vorlegt statt selbst zu entscheiden — dieselbe Klasse wie AGENTS.md
Invariante 6, auch ohne dass hier ein Exit-42-Gate greift.
Geändert: `tools/chemenu/commands/raw_cmd.py` (`_occupied_stems`,
`_stem_collision_message`, `--replaces`/`_replace`),
`tools/chemenu/tests/test_raw_cmd.py` (16 neue Tests), `raw/CONTRACT.md`,
`tools/CONTRACT.md`, `instructions/wiki-ingest/SKILL.md`. MINOR: `raw accept`
wurde nie released (letztes Release `v4.7.4`), die Verschärfung kostet also
keine Kompatibilitätsfrage, solange sie vor `4.8.0` landet.
Schließt #64.
--- ---
## 4.7.4 - 2026-09-04 - bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet ## 4.7.4 - 2026-09-04 - bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet
+1 -1
View File
@@ -1 +1 @@
4.8.0-beta.3 4.8.0-beta.4
+8
View File
@@ -30,6 +30,14 @@ pages should never have cost the concept contract. Field-level requirements alwa
in the same call, so they land bundled together rather than as two independent promotions. A in the same call, so they land bundled together rather than as two independent promotions. A
file already in `raw/` skips this step entirely. file already in `raw/` skips this step entirely.
**If this refuses because the name is already claimed** (a file stem or a bundle directory
already occupies it at `raw/<type>/` level, Gitea #64), that is not this session's call to
make: whether the incoming file is a later edition of the existing source or a second,
separate one is a judgment about the world, and the command's message names both routes -
`--replaces` and renaming in `incoming/` - without recommending either. Show the message to
the human and wait, the same way a session halts at an exit-42 gate (AGENTS.md invariant 6),
even though this refusal is a plain exit 1, not a gate.
2. **Read the source.** Read the file completely; if it is binary or an image, note its 2. **Read the source.** Read the file completely; if it is binary or an image, note its
presence and what it shows. presence and what it shows.
+29
View File
@@ -56,6 +56,26 @@ A bundle's type directory is the **source's** type, not any one file's - a diagr
a `documents/` source is promoted from `incoming/documents/`, not `incoming/assets/`; `assets/` is a `documents/` source is promoted from `incoming/documents/`, not `incoming/assets/`; `assets/` is
for a source that is itself an asset. for a source that is itself an asset.
**The names occupied at `raw/<type>/` level - file stems and bundle directory names alike - are
unique** (Gitea #64). Within a bundle, `handbuch.pdf` and `handbuch.md` sit side by side as always;
the rule bites one level up, so a second, unrelated source cannot promote quietly into a bundle it
does not belong to just because its own filename happens not to collide. A promote whose target
name is already occupied is refused, naming both sanctioned ways past it without recommending
either:
```
ERROR raw/documents/cluster.md already claims the stem "cluster" in raw/documents/.
These are two different intents and only you can tell them apart:
Same source, new edition -> tools/wikitool raw accept --replaces raw/documents/cluster.md incoming/documents/cluster.md
A second, separate source -> rename it in incoming/ (cluster-netzplan.md, cluster-2026-09.md) and accept it normally
raw accept does not guess which one this is.
```
An agent that gets this message does not pick a route on its own initiative - it shows the message
to the human and waits, the same way it would for an exit-42 gate (AGENTS.md invariant 6), even
though no gate fires here: the tool cannot ask the question itself, so the session passes it on
instead of answering it.
`tools/wikitool raw accept --page "Source - X" ...` additionally extends an existing source page's `tools/wikitool raw accept --page "Source - X" ...` additionally extends an existing source page's
`raw_files:` in the same call. If that raises the page past one file, its already-promoted file is `raw_files:` in the same call. If that raises the page past one file, its already-promoted file is
folded into the new bundle alongside the one(s) just accepted - the file that started single does folded into the new bundle alongside the one(s) just accepted - the file that started single does
@@ -70,6 +90,15 @@ moment its immutability under the rules below begins, not the moment it was drop
- **Immutable.** Never edit, reformat, summarize, or "clean up" a file after it lands here. - **Immutable.** Never edit, reformat, summarize, or "clean up" a file after it lands here.
Corrections belong in the `kb/` page that covers it, not in the source. Corrections belong in the `kb/` page that covers it, not in the source.
- **Replaceable as a whole, never in part.** A source that gets a later edition is replaced
wholesale by `raw accept --replaces`, in one commit together with the update of every `kb/`
page compiled from it. Whether a new file is a later edition of an existing source or a
second, separate source is a human's decision and never the tool's or an agent's - `raw
accept` refuses and names both routes rather than choosing one (see above). The previous
edition is not kept as a file: it is overwritten, and `git log --follow <path>` is the
archive - no `-2026-09-05` suffix, no content-hash filename, no version field, because
`raw_files:` is an identifier (invariant 2) and Git already answers "what did this used to
say" losslessly (Gitea #64).
- **Binary and image files still get ingested**, noting their presence and what they show, - **Binary and image files still get ingested**, noting their presence and what they show,
even when their content cannot be read directly. even when their content cannot be read directly.
- **Every file is expected to be covered** by some source page, and one source page may cover - **Every file is expected to be covered** by some source page, and one source page may cover
+4 -2
View File
@@ -58,7 +58,8 @@ tools/wikitool <command> --help
| `sources coverage [--json]` | List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages | | `sources coverage [--json]` | List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages |
| `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) |
| `raw accept <file> [<file> ...] [--page "<Title>"] [--dry-run]` | Promote one or more files from `incoming/<type>/` into `raw/<type>/`, computing the destination instead of taking it as an argument (`raw/CONTRACT.md` "Getting a file in", Gitea #58): the type subdirectory comes from where the file sits under `incoming/`, a bundle directory (`raw/<type>/<stem>/`, named after the first file's stem) forms only from the second file on, and one file promoted alone gets none. `--page "<Title>"` additionally extends that existing source page's `raw_files:` in the same call; if that raises the page past one file, its already-promoted file is folded into the new bundle alongside the one(s) just accepted, after checking it has no other owner (`provenance.duplicate_raw_file_owners`) - moving a file another page also claims would break that page's `raw_files:` unconsulted | | `raw accept <file> [<file> ...] [--page "<Title>"] [--dry-run]` | Promote one or more files from `incoming/<type>/` into `raw/<type>/`, computing the destination instead of taking it as an argument (`raw/CONTRACT.md` "Getting a file in", Gitea #58): the type subdirectory comes from where the file sits under `incoming/`, a bundle directory (`raw/<type>/<stem>/`, named after the first file's stem) forms only from the second file on, and one file promoted alone gets none. `--page "<Title>"` additionally extends that existing source page's `raw_files:` in the same call; if that raises the page past one file, its already-promoted file is folded into the new bundle alongside the one(s) just accepted, after checking it has no other owner (`provenance.duplicate_raw_file_owners`) - moving a file another page also claims would break that page's `raw_files:` unconsulted. The set of names occupied at `raw/<type>/` level - file stems and bundle directory names alike - must stay unique (Gitea #64): a promote whose target name already belongs to something this call does not itself own is refused, naming both `--replaces` and renaming-in-`incoming/` without recommending either |
| `raw accept <file> --replaces <raw-path> [--dry-run]` | The one sanctioned way past that uniqueness rule: overwrites `<raw-path>` in place with the single incoming file (same filename, same type directory required), leaving every page's `raw_files:` untouched and writing no `kb/` page - the previous edition survives only in `git log --follow <raw-path>` (Gitea #64 decision 3). Refuses if the target has more than one owning source page; if it has none, replaces anyway and says so. Cannot be combined with `--page` or with more than one incoming file - a replacement is one file for one file. Prints the source page (if any) and its citing pages, so their update lands in the same commit as the replacement |
| `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. **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 | | `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) |
@@ -195,7 +196,8 @@ is atomic, and whether a retry is safe.
| `work new` | Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists | Yes - one directory with two files | A collision is not transient: resume the existing run instead, or pass `--again` if the tree itself changed. Never create a numbered variant by hand | | `work new` | Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists | Yes - one directory with two files | A collision is not transient: resume the existing run instead, or pass `--again` if the tree itself changed. Never create a numbered variant by hand |
| `work close` | Unknown run key, or `--yes` was not passed | No - a recursive delete | For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` | | `work close` | Unknown run key, or `--yes` was not passed | No - a recursive delete | For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` |
| `sources coverage` / `sources trace` | Bad arguments (e.g. neither or both of `--raw`/`--page`) | Read-only | Fix the argument and retry | | `sources coverage` / `sources trace` | Bad arguments (e.g. neither or both of `--raw`/`--page`) | Read-only | Fix the argument and retry |
| `raw accept` | A file does not exist, is not under `incoming/`, sits directly in `incoming/` or nested below its type directory, files in one call disagree on type or share a name, a target path already exists, `--page` names an unknown page or one with no `raw_files:` yet, an existing `raw_files:` entry is missing on disk or under a different type directory, or a file to be moved has more than one owning page | No - one filesystem move per file, then (with `--page`) one page write | Fix the named argument and retry once. Safe to retry as-is once the cause is fixed: a file already at its computed destination is what "already exists" reports, not a partial prior run to resume. Never choose the destination by hand instead - that is the decision this command exists to take away | | `raw accept` | A file does not exist, is not under `incoming/`, sits directly in `incoming/` or nested below its type directory, files in one call disagree on type or share a name, a target path already exists, the target name is already occupied at `raw/<type>/` level by something the call does not own, `--page` names an unknown page or one with no `raw_files:` yet, an existing `raw_files:` entry is missing on disk or under a different type directory, or a file to be moved has more than one owning page | No - one filesystem move per file, then (with `--page`) one page write | Fix the named argument and retry once. Safe to retry as-is once the cause is fixed: a file already at its computed destination is what "already exists" reports, not a partial prior run to resume. A stem-occupied refusal is not fixed by retrying at all - it names `--replaces` and renaming in `incoming/` as the two routes and neither is the tool's to pick. Never choose the destination by hand instead - that is the decision this command exists to take away |
| `raw accept --replaces` | More than one incoming file, `--page` also given, the incoming filename differs from the target's, the target does not lie under `raw/` or does not exist, the incoming file sits under a different type directory than the target, or the target has more than one owning source page | No - one `unlink()` + one `rename()`, no page write | Fix the named argument and retry once. Every check runs before the filesystem is touched, so a refusal leaves both files exactly as they were |
| `types list` / `types describe` | Unknown type name | Read-only | Fix the name and retry | | `types list` / `types describe` | Unknown type name | Read-only | Fix the name and retry |
| `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 |
+142 -1
View File
@@ -20,6 +20,20 @@ that is this command's job:
Multi-owner raw files (`provenance.duplicate_raw_file_owners`) are refused Multi-owner raw files (`provenance.duplicate_raw_file_owners`) are refused
rather than silently moved: relocating a file another page also claims would rather than silently moved: relocating a file another page also claims would
break that page's `raw_files:` without it ever being consulted. break that page's `raw_files:` without it ever being consulted.
**Stem uniqueness at `raw/<type>/` level** (Gitea #64) closes the gap this
leaves open: without it, a second, unrelated source whose primary file happens
not to collide on the exact filename slips silently into an existing bundle,
because the per-file `dst.exists()` check above never looks at the bundle
directory itself. The set of names occupied at `raw/<type>/` level - file
stems and bundle directory names alike - must stay unique; `_occupied_stems()`
and the check built on it enforce that, while still allowing a call to grow a
bundle it already owns (via `--page`, or by continuing an existing bundle).
**`--replaces <raw-path>`** is the only sanctioned way past that rule: whether
a new file is a later edition of an existing source or a second, separate one
is a human's decision, never the tool's or an agent's, so the command refuses
and names both routes rather than choosing one (Gitea #64 decision 2).
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,7 +47,7 @@ from chemenu.commands._util import fail, rel_path, success
from chemenu.commands.dist_cmd import RAW_SUBDIRS from chemenu.commands.dist_cmd import RAW_SUBDIRS
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import source_pages_by_raw_file, source_raw_files from chemenu.provenance import citing_pages, source_pages_by_raw_file, source_raw_files
app = typer.Typer(help="Promote raw material out of incoming/ into raw/.") app = typer.Typer(help="Promote raw material out of incoming/ into raw/.")
@@ -70,6 +84,106 @@ def _classify(path: Path, incoming: Path) -> str:
return sub return sub
def _occupied_stems(raw_sub_dir: Path) -> dict[str, Path]:
"""Every name occupied at `raw/<type>/` level: file stems and bundle
directory names alike, one level below `raw_sub_dir` only."""
occupied: dict[str, Path] = {}
if raw_sub_dir.is_dir():
for entry in raw_sub_dir.iterdir():
occupied[entry.stem if entry.is_file() else entry.name] = entry
return occupied
def _stem_collision_message(sub: str, claimed_name: str, holder: Path) -> str:
example_target = holder
if holder.is_dir():
children = sorted(holder.iterdir())
example_target = children[0] if children else holder
return (
f'{rel_path(holder)} already claims the stem "{claimed_name}" in raw/{sub}/.\n'
" These are two different intents and only you can tell them apart:\n"
f" Same source, new edition -> tools/wikitool raw accept "
f"--replaces {rel_path(example_target)} <incoming file>\n"
" A second, separate source -> rename it in incoming/ (add a distinguishing "
"suffix) and accept it normally\n"
" raw accept does not guess which one this is."
)
def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bool) -> None:
"""`raw accept --replaces <target> <incoming file>` - overwrite an
existing raw/ file wholesale with a later edition, in place, with
`raw_files:` on every source page left untouched (Gitea #64 decision 2).
Every check below runs before any write, so a failure leaves both the
target and the incoming file exactly as they were - the invariant the
issue names for this destructive step.
"""
if page is not None:
fail("--replaces and --page cannot be combined - a replacement never changes raw_files:.")
if len(files) != 1:
fail("--replaces takes exactly one incoming file - a replacement is one file for one file.")
incoming_path = _resolve(files[0])
if not incoming_path.is_file():
fail(f"{rel_path(incoming_path)} does not exist or is not a file.")
incoming_sub = _classify(incoming_path, _incoming_dir())
target = _resolve(replaces)
try:
target_rel = target.relative_to(config.RAW_DIR)
except ValueError:
fail(f"--replaces target {rel_path(target)} does not lie under raw/.")
if not target.is_file():
fail(f"--replaces target {rel_path(target)} does not exist or is not a file.")
target_sub = target_rel.parts[0]
if target_sub != incoming_sub:
fail(
f"--replaces target is under raw/{target_sub}/, but {rel_path(incoming_path)} is "
f"under incoming/{incoming_sub}/ - a replacement stays within one type directory."
)
if target.name != incoming_path.name:
fail(
f"--replaces target {rel_path(target)} has a different filename than "
f"{rel_path(incoming_path)} - a rename is not part of a replacement (see `raw rename`, #16)."
)
pages = load_kb_pages(config.KB_DIR)
by_raw = source_pages_by_raw_file(pages)
owners = sorted(set(by_raw.get(rel_path(target), [])))
if len(owners) > 1:
fail(
f"Cannot replace {rel_path(target)}: more than one source page claims it "
f"({', '.join(owners)}). Resolve the multiple ownership first (see `wikitool sources coverage`)."
)
if dry_run:
typer.echo(f"[dry-run] would replace {rel_path(target)} with {rel_path(incoming_path)}. No files written.")
return
target.unlink()
incoming_path.rename(target)
if not owners:
success(
f"Replaced {rel_path(target)} (previous version stays in git history). "
"No source page covers this file - see `wikitool sources coverage`."
)
return
source_title = owners[0]
citers = citing_pages(pages, source_title)
typer.echo(f"Replaced {rel_path(target)} (previous version stays in git history).")
typer.echo("These pages were compiled against the previous version and may now be stale:")
typer.echo(f" [[{source_title}]]")
if citers:
for citer in citers:
typer.echo(f" cited by: [[{citer}]]")
else:
typer.echo(" cited by: (nothing yet)")
success("Review them in this same run: the replacement and their update belong in one commit.")
@app.command("accept") @app.command("accept")
def raw_accept_command( def raw_accept_command(
files: list[Path] = typer.Argument( files: list[Path] = typer.Argument(
@@ -82,6 +196,12 @@ def raw_accept_command(
help="Extend this existing source page's raw_files: with the promoted file(s), " help="Extend this existing source page's raw_files: with the promoted file(s), "
"folding in its already-promoted file if this raises it past one", "folding in its already-promoted file if this raises it past one",
), ),
replaces: Optional[Path] = typer.Option(
None,
"--replaces",
help="Overwrite this existing raw/ file in place with the single incoming/ file passed "
"alongside it - the only sanctioned way past the stem-uniqueness rule below",
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"), dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
): ):
"""Promote file(s) from incoming/ into raw/, computing the destination """Promote file(s) from incoming/ into raw/, computing the destination
@@ -91,6 +211,11 @@ def raw_accept_command(
fail("Pass at least one file to promote.") fail("Pass at least one file to promote.")
incoming = _incoming_dir() incoming = _incoming_dir()
if replaces is not None:
_replace(files, replaces, page, dry_run)
return
resolved = [_resolve(f) for f in files] resolved = [_resolve(f) for f in files]
for path in resolved: for path in resolved:
@@ -168,6 +293,22 @@ def raw_accept_command(
if dst.exists(): if dst.exists():
fail(f"Cannot promote: {rel_path(dst)} already exists.") fail(f"Cannot promote: {rel_path(dst)} already exists.")
# Stem uniqueness at raw/<type>/ level (Gitea #64): the name this call is
# about to claim there - the bundle's name, or the lone file's stem when no
# bundle forms - must not already belong to something this call does not
# itself own. "Owns" means: one of the page's already-registered raw files
# (the pitfall from the module docstring - a single file growing into a
# bundle of its own name momentarily still occupies that name), or, once a
# bundle already has >=2 registered files, the bundle directory itself.
claimed_name = bundle_dir.name if bundle_dir is not None else resolved[0].stem
occupied = _occupied_stems(raw_sub_dir)
owned = set(existing_raw_paths)
if len(existing_raw_paths) >= 2:
owned.add(bundle_dir)
holder = occupied.get(claimed_name)
if holder is not None and holder not in owned:
fail(_stem_collision_message(sub, claimed_name, holder))
moving_existing = [src for src, _dst in moves if src in existing_raw_paths] moving_existing = [src for src, _dst in moves if src in existing_raw_paths]
if moving_existing: if moving_existing:
by_raw = source_pages_by_raw_file(pages) by_raw = source_pages_by_raw_file(pages)
+227 -2
View File
@@ -20,8 +20,8 @@ def tree(kb_dir):
return root return root
def _accept(*files, page=None, dry_run=False): def _accept(*files, page=None, replaces=None, dry_run=False):
return raw_accept_command(files=list(files), page=page, dry_run=dry_run) return raw_accept_command(files=list(files), page=page, replaces=replaces, dry_run=dry_run)
def _write_source(kb_dir, title, raw_files): def _write_source(kb_dir, title, raw_files):
@@ -254,3 +254,228 @@ def test_type_directory_mismatch_with_existing_raw_files_is_rejected(tree):
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
_accept(new_file, page="Source - Handbuch") _accept(new_file, page="Source - Handbuch")
assert new_file.exists() assert new_file.exists()
# --- Stem uniqueness at raw/<type>/ level (Gitea #64) ---
def test_second_source_does_not_silently_join_an_existing_bundle(tree):
"""The exact repro from #64: a bundle exists for one source, and a second,
unrelated source's files (no --page) happen not to collide on filename -
they must not slip into the existing bundle."""
bundle = tree / "raw/documents/handbuch"
bundle.mkdir()
(bundle / "handbuch.pdf").write_bytes(b"pdf")
(bundle / "handbuch.md").write_text("md", encoding="utf-8")
txt = tree / "incoming/documents/handbuch.txt"
anhang = tree / "incoming/documents/anhang.md"
txt.write_text("txt", encoding="utf-8")
anhang.write_text("anhang", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(txt, anhang)
assert txt.exists() and anhang.exists()
assert sorted(p.name for p in bundle.iterdir()) == ["handbuch.md", "handbuch.pdf"]
def test_flat_promote_rejected_when_stem_matches_an_existing_bundle(tree):
(tree / "raw/documents/handbuch").mkdir()
(tree / "raw/documents/handbuch/handbuch.pdf").write_bytes(b"pdf")
txt = tree / "incoming/documents/handbuch.txt"
txt.write_text("txt", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(txt)
assert txt.exists()
assert (tree / "raw/documents/handbuch/handbuch.pdf").exists()
def test_flat_promote_rejected_when_stem_matches_an_existing_flat_file(tree):
"""Same stem, different extension: not caught by the old per-path
dst.exists() check, which is the whole gap #64 closes."""
(tree / "raw/documents/handbuch.pdf").write_bytes(b"pdf")
md = tree / "incoming/documents/handbuch.md"
md.write_text("md", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(md)
assert md.exists()
assert (tree / "raw/documents/handbuch.pdf").read_bytes() == b"pdf"
assert not (tree / "raw/documents/handbuch.md").exists()
def test_stem_collision_message_names_both_routes_without_recommending_one(tree, capsys):
(tree / "raw/documents/handbuch.pdf").write_bytes(b"pdf")
md = tree / "incoming/documents/handbuch.md"
md.write_text("md", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(md)
err = capsys.readouterr().out
assert "--replaces" in err
assert "rename" in err
assert "does not guess" in err
# --- --replaces (Gitea #64 decision 2) ---
def test_replaces_swaps_bytes_and_leaves_raw_files_untouched(tree):
_write_source(tree / "kb", "Source - Handbuch", ["raw/documents/handbuch.md"])
target = tree / "raw/documents/handbuch.md"
target.write_text("old edition", encoding="utf-8")
new = tree / "incoming/documents/handbuch.md"
new.write_text("new edition", encoding="utf-8")
_accept(new, replaces=target)
assert target.read_text(encoding="utf-8") == "new edition"
assert not new.exists()
frontmatter, _ = read_page(tree / "kb/sources/Source - Handbuch.md")
assert frontmatter["raw_files"] == ["raw/documents/handbuch.md"]
def test_replaces_reports_source_and_both_citing_pages(tree, capsys):
"""AC: one source page, two citing pages - all three named in the output."""
_write_source(tree / "kb", "Source - Handbuch", ["raw/documents/handbuch.md"])
write_page(
tree / "kb/concepts/Handbuch-Konzept.md",
{
"type": "types/concept.md", "concept_type": "protocol", "tags": [],
"created": "2026-09-01", "modified": "2026-09-01", "related": [],
"sources": ["Source - Handbuch"], "confidence": 0.7,
},
"\n# Handbuch-Konzept\n\n## Definition\n\nx.\n",
)
write_page(
tree / "kb/entities/tools/handbuch-tool.md",
{
"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-09-01", "modified": "2026-09-01", "related": [],
"sources": ["Source - Handbuch"], "confidence": 0.7,
},
"\n# handbuch-tool\n\n## Description\n\nx.\n",
)
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
_accept(new, replaces=target)
out = capsys.readouterr().out
assert "Source - Handbuch" in out
assert "Handbuch-Konzept" in out
assert "handbuch-tool" in out
def test_replaces_succeeds_with_no_owner_and_reports_it(tree, capsys):
target = tree / "raw/documents/orphan.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/orphan.md"
new.write_text("new", encoding="utf-8")
_accept(new, replaces=target)
assert target.read_text(encoding="utf-8") == "new"
out = capsys.readouterr().out
assert "source page covers this file" in out
def test_replaces_dry_run_writes_nothing(tree):
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
_accept(new, replaces=target, dry_run=True)
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()
def test_replaces_rejects_more_than_one_incoming_file(tree):
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
a = tree / "incoming/documents/handbuch.md"
b = tree / "incoming/documents/extra.md"
a.write_text("a", encoding="utf-8")
b.write_text("b", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(a, b, replaces=target)
assert target.read_text(encoding="utf-8") == "old"
assert a.exists() and b.exists()
def test_replaces_rejects_filename_mismatch(tree):
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/handbuch-v2.md"
new.write_text("new", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(new, replaces=target)
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()
def test_replaces_rejects_type_directory_mismatch(tree):
target = tree / "raw/notes/handbuch.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(new, replaces=target)
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()
def test_replaces_rejects_nonexistent_target(tree):
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(new, replaces=tree / "raw/documents/handbuch.md")
assert new.exists()
def test_replaces_rejects_multi_owner_target(tree):
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
_write_source(tree / "kb", "Source - Handbuch", ["raw/documents/handbuch.md"])
_write_source(tree / "kb", "Source - Also Handbuch", ["raw/documents/handbuch.md"])
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(new, replaces=target)
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()
def test_replaces_rejects_combination_with_page(tree):
_write_source(tree / "kb", "Source - Handbuch", ["raw/documents/handbuch.md"])
target = tree / "raw/documents/handbuch.md"
target.write_text("old", encoding="utf-8")
new = tree / "incoming/documents/handbuch.md"
new.write_text("new", encoding="utf-8")
with pytest.raises(typer.Exit):
_accept(new, replaces=target, page="Source - Handbuch")
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()