diff --git a/CHANGES.md b/CHANGES.md index 4a64a2d..14b1d02 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,16 @@ place only: `instructions/dev/version-parts.md` § The candidate model. --- +## 10.1.1-beta.1 - 2026-10-16 - Reine Lesevorgänge schreiben keinen Log-Eintrag mehr, und ein offener Log-Eintrag allein lässt den Katalog beim Abgleich unangetastet + +**Author:** Torben Nehmer + + +- Reine Lesevorgänge schreiben keinen Log-Eintrag mehr, und ein offener Log-Eintrag allein lässt den Katalog beim Abgleich unangetastet + + +--- + ## 10.1.0 - 2026-10-16 - Große PDFs und lange Quellen beim Ingest, vollständiger Export, Konverter-Tests gegen echte Dokumente **Author:** Torben Nehmer diff --git a/VERSION b/VERSION index 4149c39..f4c4a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -10.1.0 +10.1.1-beta.1 diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 7a93d7f..fe04443 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -108,7 +108,7 @@ chemenu/ ├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection │ ├── CONTRACT.md # Collections, naming, tone, linking, provenance │ ├── index.md # Generated catalog *map*: counts and pointers -│ ├── log.md # Generated chronological audit log +│ ├── log.md # Generated audit log, in the order entries arrived │ ├── provenance.md # Generated raw-file reverse index │ ├── entities/ # COLLECTION.md + INDEX.md + areas below │ │ ├── codebases/ diff --git a/docs/reference/commands/catalog-and-log.md b/docs/reference/commands/catalog-and-log.md index 1dfe953..150925c 100644 --- a/docs/reference/commands/catalog-and-log.md +++ b/docs/reference/commands/catalog-and-log.md @@ -66,13 +66,13 @@ Append a formatted entry to `kb/log.md`. **Synopsis** -- `wikitool log append --op ingest|query|lint|create|update|delete|rename|move --title "..." [--body "..."|--body-file path]` +- `wikitool log append --op ingest|lint|create|update|delete|rename|move|query --title "..." [--body "..."|--body-file path]` **Options** ```text - --op ingest|query|lint|create|update|delete|rename|move - [required] + --op ingest|query|lint|create|update|delete|rename|move (query: + legacy, no longer written by the stack) [required] --title Brief description, e.g. a source path [required] --body Optional multi-line details --body-file Read the body from a file instead of --body @@ -90,6 +90,7 @@ tools/wikitool log append --op lint --title "2026-09-26" --body-file lint-summar - Appends one entry to `kb/log.md`: a `## [YYYY-MM-DD] | ` heading, the body if one is given, and a `---` separator. - Not idempotent: every successful run appends a new entry, including a repeated one. +- `query` is a legacy value: older versions wrote one entry per read-only query. It is still accepted so existing logs and an instance's own instructions keep working, but the stack no longer writes it - a read is not an operation on the wiki. **Properties** diff --git a/docs/reference/commands/git.md b/docs/reference/commands/git.md index 5060d24..1e3d431 100644 --- a/docs/reference/commands/git.md +++ b/docs/reference/commands/git.md @@ -42,7 +42,7 @@ tools/wikitool sync --confirm-rebase <token> # re-run after exit 42, once the **Behaviour** - Fetches `<remote>/<branch>`, then: fast-forwards when only the remote moved; rebases the local commits on top when both sides moved but touched disjoint files; exits 42 (Rebase-Review Gate) when both sides touched the same file. -- Files `wikitool` generates (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `kb/**/INDEX.md`) never count as an overlap and never reach the gate. Where they are what stops git - uncommitted ones in the way of a fast-forward or rebase, or a rebase conflict in them - they are merged mechanically: `kb/log.md` becomes the incoming log plus the entries only this side appended, and the catalog and `kb/provenance.md` are regenerated from the merged pages and left as an uncommitted change. A reconcile git can do on its own is unchanged and regenerates nothing. +- Files `wikitool` generates (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `kb/**/INDEX.md`) never count as an overlap and never reach the gate. Where they are what stops git - uncommitted ones in the way of a fast-forward or rebase, or a rebase conflict in them - they are merged mechanically: `kb/log.md` becomes the incoming log plus the entries only this side appended, and the catalog and `kb/provenance.md` are regenerated from the merged pages and left as an uncommitted change. When only `kb/log.md` stood in the way - an open log entry, nothing else generated - the catalog is not rebuilt: the incoming one stays, and just the local entries are appended again. A reconcile git can do on its own is unchanged and regenerates nothing. - Uncommitted changes that no incoming commit touches do not stop a rebase: they are carried through it (`git rebase --autostash`) and come back unchanged. - Before anything is set aside, the working tree is stored as a commit under `refs/wikitool/reconcile-backup`. A successful reconcile drops it; a failed one restores the working tree and names the ref. - A refused call performs no rebase attempt and leaves the branch where it was. diff --git a/instructions/publish-cycle.md b/instructions/publish-cycle.md index b72eac4..c565769 100644 --- a/instructions/publish-cycle.md +++ b/instructions/publish-cycle.md @@ -30,7 +30,7 @@ consistent. 3. **Append the audit entry** - one per operation: ```bash - tools/wikitool log append --op ingest|query|lint|create|update|delete|rename|move \ + tools/wikitool log append --op ingest|lint|create|update|delete|rename|move \ --title "<what>" --body "<outcome>" ``` diff --git a/instructions/session-setup.md b/instructions/session-setup.md index 5c64b6d..12675d6 100644 --- a/instructions/session-setup.md +++ b/instructions/session-setup.md @@ -88,7 +88,8 @@ the session instead of discovering the drift only at the very end. never commits and never pushes. Files `wikitool` generates are never a reason to stop: when the catalog or `kb/log.md` changed on both sides, `sync` keeps both sides' log entries and regenerates the catalog and `kb/provenance.md`, which it leaves as an uncommitted change for the -next `publish`. Uncommitted work that the incoming commits do not touch stays where it is. +next `publish`. An open log entry alone is no such case: the incoming catalog stays and only the +entry is appended again. Uncommitted work that the incoming commits do not touch stays where it is. **Exit 42 (rebase-review)?** Same as any exit 42 - read the diff it prints, judge whether it conflicts with what you are about to do, summarize that to the user, then `tools/wikitool sync --confirm-rebase <token>` before continuing. See diff --git a/instructions/wiki-query/SKILL.md b/instructions/wiki-query/SKILL.md index eeaf80d..9cda333 100644 --- a/instructions/wiki-query/SKILL.md +++ b/instructions/wiki-query/SKILL.md @@ -12,10 +12,11 @@ description: Answers a question using the LLM wiki's compiled knowledge - read-o **Before the first `wikitool` call:** `instructions/session-setup.md`. **Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a -page while answering. Two exceptions, both mechanical: step 7 (filing a valuable answer through -`wikitool new`, never by hand) and step 8 (one audit entry via `wikitool log append`). If the -wiki has no confident source, say so - per AGENTS.md's "never file an unsourced answer" -invariant - rather than synthesizing a plausible-sounding answer from general knowledge. +page while answering. One exception, mechanical: step 7 (filing a valuable answer through +`wikitool new`, never by hand). A query writes no `kb/log.md` entry - a read changes nothing the +wiki learned. If the wiki has no confident source, say so - per AGENTS.md's "never file an +unsourced answer" invariant - rather than synthesizing a plausible-sounding answer from general +knowledge. ## Steps @@ -98,19 +99,16 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn find again than a sentence in a chat log. 7. **File back what survived.** Scaffold it - `tools/wikitool new ...` - and follow - `wiki-manage`. Never write the page by hand, and never file an answer no source backs. - -8. **Log it.** - - ```bash - tools/wikitool log append --op query --title "<question>" --body "<outcome>" - ``` + `wiki-manage`, which logs it as a `create` through `publish-cycle.md`. Never write the page by + hand, and never file an answer no source backs. ## Decision points - **Nothing found?** Try the structural query before concluding the wiki is silent - a page may exist under different words. Then say the wiki has no confident source, and offer to ingest - one. + one. If the user wants the gap kept, file it as an inbox item on their say-so: + `tools/wikitool task new --inbox --title "<the question>"`. Without a configured tracker the + offer stays in the chat. Never log it. - **The question asks for the wording** - "what exactly does rule 16:3e say", "which value is in table 4"? Find the page that carries the substance first (steps 2-3), then step 4's route to the passage. A paraphrase from the page is not the wording, and searching `raw/` is not a @@ -129,7 +127,7 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn ## wikitool commands used -`search`, `log append`. If filing an answer back: `new`, `xref add`, `sources rebuild-index`, +`search`. If filing an answer back: `new`, `xref add`, `sources rebuild-index`, `index rebuild`, and `publish` only if asked. ## Output diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 69698f9..b90c41e 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -1035,7 +1035,7 @@ Append a formatted entry to `kb/log.md`. **SYNOPSIS** -- `wikitool log append --op ingest|query|lint|create|update|delete|rename|move --title "..." [--body "..."|--body-file path]` +- `wikitool log append --op ingest|lint|create|update|delete|rename|move|query --title "..." [--body "..."|--body-file path]` **PROPERTIES** @@ -1069,6 +1069,7 @@ Append a formatted entry to `kb/log.md`. - Appends one entry to `kb/log.md`: a `## [YYYY-MM-DD] <op> | <title>` heading, the body if one is given, and a `---` separator. - Not idempotent: every successful run appends a new entry, including a repeated one. +- `query` is a legacy value: older versions wrote one entry per read-only query. It is still accepted so existing logs and an instance's own instructions keep working, but the stack no longer writes it - a read is not an operation on the wiki. **SEE ALSO** @@ -2142,7 +2143,7 @@ Fetch `<remote>/<branch>` and bring the local branch up to date with it. **NOTES** - Fetches `<remote>/<branch>`, then: fast-forwards when only the remote moved; rebases the local commits on top when both sides moved but touched disjoint files; exits 42 (Rebase-Review Gate) when both sides touched the same file. -- Files `wikitool` generates (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `kb/**/INDEX.md`) never count as an overlap and never reach the gate. Where they are what stops git - uncommitted ones in the way of a fast-forward or rebase, or a rebase conflict in them - they are merged mechanically: `kb/log.md` becomes the incoming log plus the entries only this side appended, and the catalog and `kb/provenance.md` are regenerated from the merged pages and left as an uncommitted change. A reconcile git can do on its own is unchanged and regenerates nothing. +- Files `wikitool` generates (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `kb/**/INDEX.md`) never count as an overlap and never reach the gate. Where they are what stops git - uncommitted ones in the way of a fast-forward or rebase, or a rebase conflict in them - they are merged mechanically: `kb/log.md` becomes the incoming log plus the entries only this side appended, and the catalog and `kb/provenance.md` are regenerated from the merged pages and left as an uncommitted change. When only `kb/log.md` stood in the way - an open log entry, nothing else generated - the catalog is not rebuilt: the incoming one stays, and just the local entries are appended again. A reconcile git can do on its own is unchanged and regenerates nothing. - Uncommitted changes that no incoming commit touches do not stop a rebase: they are carried through it (`git rebase --autostash`) and come back unchanged. - Before anything is set aside, the working tree is stored as a commit under `refs/wikitool/reconcile-backup`. A successful reconcile drops it; a failed one restores the working tree and names the ref. - A refused call performs no rebase attempt and leaves the branch where it was. diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index 3231d27..c90377c 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -859,7 +859,9 @@ def rebase_review_token(remote: str, branch: str, local_before: str, remote_tip: # # The path below takes the generated files out of git's way and puts the result back # together mechanically: the log is the incoming version plus what only this side appended, -# and the catalog and kb/provenance.md are regenerated from the merged pages. It runs only +# and the catalog and kb/provenance.md are regenerated from the merged pages - unless the log +# was the only generated file in the way: then the incoming catalog is already right for the +# incoming pages, and only the log entries are appended again. It runs only # where git would otherwise have failed, so a reconcile that succeeds today is unchanged - # in particular it does not rebuild, because `index rebuild` stamps today's date into # kb/index.md and every `sync` would then end with a modified file. @@ -1049,22 +1051,34 @@ def _resolve_generated_conflicts() -> Optional[str]: def _rebase_resolving_generated(remote_ref: str) -> tuple[bool, str, bool]: """`git rebase --autostash <remote_ref>`, resolving each step whose conflicts are all in - generated files. Returns (succeeded, detail, resolved anything). Any other stop is aborted, + generated files. Returns (succeeded, detail, resolved a generated file other than the log - the + log's own conflicts are merged without needing a rebuild). Any other stop is aborted, which also hands the autostash back.""" result = _run(["git", "rebase", "--autostash", remote_ref], env=_NO_EDITOR) resolved = False while result.returncode != 0: if not _rebase_in_progress(): return False, result.stderr, resolved + listed = _run(["git", "diff", "--name-only", "--diff-filter=U", "-z"]).stdout + unmerged = [path for path in listed.split("\0") if path] reason = _resolve_generated_conflicts() if reason: _run(["git", "rebase", "--abort"]) return False, f"{reason}\n{result.stderr}", resolved - resolved = True + # A conflict in the log alone is resolved by the append-merge and leaves the catalog + # as the incoming commits wrote it; only another generated file needs a rebuild. + resolved = resolved or any(path != GENERATED_LOG for path in unmerged) result = _run(["git", "rebase", "--continue"], env=_NO_EDITOR) return True, "", resolved +def append_log_suffix(log_suffix: bytes) -> None: + """Append what only this side added to kb/log.md back onto the incoming version.""" + if log_suffix: + with (config.ROOT / GENERATED_LOG).open("ab") as log: + log.write(log_suffix) + + def regenerate_generated(log_suffix: bytes = b"") -> None: """Append `log_suffix` to kb/log.md, then rewrite the catalog and kb/provenance.md from the pages in the working tree - what `index rebuild` and `sources rebuild-index` write. The log @@ -1072,9 +1086,7 @@ def regenerate_generated(log_suffix: bytes = b"") -> None: from chemenu.commands.index_build import write_index from chemenu.commands.provenance_cmd import write_provenance_index - if log_suffix: - with (config.ROOT / GENERATED_LOG).open("ab") as log: - log.write(log_suffix) + append_log_suffix(log_suffix) write_index(config.KB_DIR) write_provenance_index(config.KB_DIR, config.RAW_DIR) @@ -1084,9 +1096,13 @@ def _around_generated(generated: list[str], operation, regenerate: bool) -> tupl (succeeded, detail, resolved generated conflicts) - and then either regenerate or put everything back. Returns (succeeded, detail, regenerated). - Regenerates when something generated was set aside or resolved, or when `regenerate` says - both sides' commits touched a generated file - a textual merge of two catalogs can succeed - and still be wrong. The backup ref is dropped on success and kept, and named, on any + Regenerates the catalog and kb/provenance.md when a generated file other than kb/log.md + was set aside or had a conflict resolved, or when `regenerate` says both sides' commits + touched a generated file - a textual merge of two catalogs can succeed and still be wrong. + When kb/log.md was all that stood in the way, the catalog the incoming commits brought is + already right for the incoming pages, and only the local log entries are appended again: + rebuilding would stamp today's date into kb/index.md and leave a changed file behind that + no local change explains. The backup ref is dropped on success and kept, and named, on any failure.""" backup = _back_up_working_tree() if backup is None: @@ -1105,9 +1121,11 @@ def _around_generated(generated: list[str], operation, regenerate: bool) -> tupl f"{detail.rstrip()}\nThe working tree was restored; as it stood before, it is also " f"kept at {BACKUP_REF} ({backup[:12]})." ), False - regenerated = bool(generated) or resolved or regenerate + regenerated = any(path != GENERATED_LOG for path in generated) or resolved or regenerate if regenerated: regenerate_generated(aside.log_suffix) + else: + append_log_suffix(aside.log_suffix) _run(["git", "update-ref", "-d", BACKUP_REF]) return True, "", regenerated @@ -1119,7 +1137,8 @@ class ReconcileOutcome: "up-to-date", "fast-forwarded", "local-ahead", "rebased", "needs-review", "conflict". The first three are the ways the fetch can fail; `detail` then carries the remote's URL. `regenerated` says the catalog and kb/provenance.md were rewritten afterwards and are an - uncommitted change now.""" + uncommitted change now. It stays False when only kb/log.md was set aside: its local entries + are appended again and the catalog is the incoming one.""" status: str pulled_commits: list[str] = field(default_factory=list) overlap_files: list[str] = field(default_factory=list) @@ -1421,8 +1440,11 @@ def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command "what stops git - uncommitted ones in the way of a fast-forward or rebase, or a rebase " "conflict in them - they are merged mechanically: `kb/log.md` becomes the incoming log " "plus the entries only this side appended, and the catalog and `kb/provenance.md` are " - "regenerated from the merged pages and left as an uncommitted change. A reconcile git " - "can do on its own is unchanged and regenerates nothing.", + "regenerated from the merged pages and left as an uncommitted change. When only " + "`kb/log.md` stood in the way - an open log entry, nothing else generated - the " + "catalog is not rebuilt: the incoming one stays, and just the local entries are " + "appended again. A reconcile git can do on its own is unchanged and regenerates " + "nothing.", "Uncommitted changes that no incoming commit touches do not stop a rebase: they are " "carried through it (`git rebase --autostash`) and come back unchanged.", "Before anything is set aside, the working tree is stored as a commit under " diff --git a/tools/chemenu/commands/log_append.py b/tools/chemenu/commands/log_append.py index 7b79644..886d4a1 100644 --- a/tools/chemenu/commands/log_append.py +++ b/tools/chemenu/commands/log_append.py @@ -55,7 +55,7 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int: path="log append", summary="Append a formatted entry to `kb/log.md`.", synopsis=(cli_contract.Variant( - usage="log append --op ingest|query|lint|create|update|delete|rename|move " + usage="log append --op ingest|lint|create|update|delete|rename|move|query " '--title "..." [--body "..."|--body-file path]', ),), properties=cli_contract.Properties( @@ -68,6 +68,9 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int: "Appends one entry to `kb/log.md`: a `## [YYYY-MM-DD] <op> | <title>` heading, the body " "if one is given, and a `---` separator.", "Not idempotent: every successful run appends a new entry, including a repeated one.", + "`query` is a legacy value: older versions wrote one entry per read-only query. It is " + "still accepted so existing logs and an instance's own instructions keep working, but " + "the stack no longer writes it - a read is not an operation on the wiki.", ), failures=( cli_contract.Failure( @@ -94,7 +97,9 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int: ), )) def log_append( - op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)), + op: str = typer.Option( + ..., "--op", help="|".join(VALID_OPS) + " (query: legacy, no longer written by the stack)", + ), title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"), body: str = typer.Option("", "--body", help="Optional multi-line details"), body_file: Optional[Path] = typer.Option(None, "--body-file", help="Read the body from a file instead of --body"), diff --git a/tools/chemenu/dist_templates/log.md b/tools/chemenu/dist_templates/log.md index 28fc6e9..31633d5 100644 --- a/tools/chemenu/dist_templates/log.md +++ b/tools/chemenu/dist_templates/log.md @@ -13,6 +13,6 @@ Each entry records what happened, when, and with what result. --- ``` -Operation types: `ingest`, `query`, `lint`, `create`, `update`, `delete` +Operation types: `ingest`, `lint`, `create`, `update`, `delete`, `rename`, `move` --- diff --git a/tools/chemenu/tests/test_git_publish.py b/tools/chemenu/tests/test_git_publish.py index 8be6356..2a74917 100644 --- a/tools/chemenu/tests/test_git_publish.py +++ b/tools/chemenu/tests/test_git_publish.py @@ -1662,6 +1662,69 @@ def test_case_1_on_sync_leaves_the_regenerated_catalog_uncommitted(wiki_repo): cwd=wiki_repo).returncode != 0 # dropped on success +def _log_only_entry(root, title="a question"): + """What a read-only query used to leave behind: one appended log entry, nothing else.""" + with (root / "kb/log.md").open("a", encoding="utf-8", newline="\n") as log: + log.write("\n" + format_log_entry("query", title, today="2026-10-06")) + + +def _backup_ref_exists(root): + return subprocess.run(["git", "show-ref", "--verify", "-q", git_publish.BACKUP_REF], + cwd=root).returncode == 0 + + +def _assert_catalog_is_the_incoming_one(root): + """kb/index.md and kb/provenance.md are byte for byte what origin/main holds.""" + check = _check_tree(root) + for path in ("kb/index.md", "kb/provenance.md"): + assert (root / path).read_bytes() == (check / path).read_bytes(), path + + +def test_an_open_log_entry_alone_does_not_regenerate_the_catalog_on_a_fast_forward(wiki_repo): + writer = _clone_writer(wiki_repo) + _writer_ingest(writer, "Writer") + _log_only_entry(wiki_repo) + + outcome = reconcile("origin", "main", None) + + assert (outcome.status, outcome.regenerated) == ("fast-forwarded", False) + assert _status(wiki_repo).split() == ["M", "kb/log.md"] + _assert_catalog_is_the_incoming_one(wiki_repo) + assert _logged_titles(wiki_repo) == ["Base", "Writer", "a question"] + assert not _backup_ref_exists(wiki_repo) + + +def test_an_open_log_entry_alone_does_not_regenerate_the_catalog_on_a_rebase(wiki_repo): + """A stranded commit that touches no generated file, an open log entry, and an ingest that + landed meanwhile: rebased, the catalog stays the incoming one, the log keeps both.""" + writer = _clone_writer(wiki_repo) + _writer_ingest(writer, "Writer") + (wiki_repo / "local.md").write_text("local\n", encoding="utf-8") + _commit_all(wiki_repo, "stranded, touches no generated file") + _log_only_entry(wiki_repo) + + outcome = reconcile("origin", "main", None) + + assert (outcome.status, outcome.regenerated) == ("rebased", False) + assert _status(wiki_repo).split() == ["M", "kb/log.md"] + _assert_catalog_is_the_incoming_one(wiki_repo) + assert _logged_titles(wiki_repo) == ["Base", "Writer", "a question"] + assert (wiki_repo / "local.md").is_file() + assert not _backup_ref_exists(wiki_repo) + + +def test_an_open_log_entry_plus_an_uncommitted_page_still_regenerates(wiki_repo): + """The catalog was locally changed too, so it is set aside and has to be rebuilt.""" + writer = _clone_writer(wiki_repo) + _writer_ingest(writer, "Writer") + _ingest(wiki_repo, "Local") + + outcome = reconcile("origin", "main", None) + + assert (outcome.status, outcome.regenerated) == ("fast-forwarded", True) + _assert_generated_current(wiki_repo) + + def test_case_2_two_committed_ingests_rebase_without_the_review_gate(wiki_repo): writer = _clone_writer(wiki_repo) _writer_ingest(writer, "Writer")