instructions: the agent speaks the KB language in chat - AGENTS.md § Conversation language, and instructions sync compiles the language tag into every published SKILL.md (#116); 10.0.0-beta.9
CI / verify (push) Successful in 8m6s
CI / pwsh (push) Successful in 2m50s
Release / release (push) Successful in 33s
Release / wiki-mirror (push) Skipped

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/explanation/language-boundaries.md
- docs/reference/commands/types-instructions-and-docs.md
- instructions/CONTRACT.md
- instructions/dev/stack-build/SKILL.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-dev/SKILL.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/instructions_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_instructions_cmd.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-12 06:57:51 +02:00
1 parent c06bde2e95
commit 0f0a5799fd
16 files changed
+385 -54

No files matched your search

+19 -10
View File
@@ -13,6 +13,7 @@ maintained permanently; anything mechanical is done by `tools/wikitool`, never b
- [Bootstrap](#bootstrap)
- [Invariants](#invariants)
- [Conversation language](#conversation-language)
- [File naming](#file-naming)
- [Personalization](#personalization)
- [Environment](#environment)
@@ -81,6 +82,13 @@ These hold regardless of which skill is active or which part of this file is in
8. **One rule, one place.** Every normative rule lives at exactly one location; everywhere
else links to it. Writing a second copy is how the two start disagreeing.
## Conversation language
Write every message to the user - progress notes and the closing report included - in the KB
language: `language:` in `kb/CONVENTIONS.md`'s frontmatter, read at session start. This holds
whatever language a file, a tool's output or a source is in; quoted commands, paths and field
names stay as they are.
## File naming
What a file is called says who it is for and how it is loaded. This is a rule, not a habit;
@@ -111,9 +119,10 @@ 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
happened once: no README may hold a copy of the `wikitool` command table.
**Two languages, and which is which.** Which one a line is written in follows from the *For*
**Two rules for the language of a file.** Which one a line is written in follows from the *For*
column above - who reads it - and from nothing else: not from who owns the file, and not from
whether it ever leaves this checkout.
whether it ever leaves this checkout. What an agent says in the chat is a third matter, and
has its own section: [Conversation language](#conversation-language).
1. **The control plane is written in English** - this file, `CLAUDE.md`, every `CONTRACT.md`,
everything under `instructions/`, and the type-specs for non-page artifacts. Quoted
@@ -131,20 +140,20 @@ whether it ever leaves this checkout.
moves this rule;
[docs/explanation/language-boundaries.md](docs/explanation/language-boundaries.md) has the
reasoning.
2. **An agent speaks the instance's KB language**, whatever this file is written in. The value
lives in `kb/CONVENTIONS.md`'s `language:` and nowhere else; an instruction that models a
sentence for the user writes it in English like the rest of the control plane, and the agent
says it in that language.
3. **The stack's human documentation is written in English** - `README.md`, `INSTALL.md` and
An instruction that models a sentence for the user writes that model in English like the rest
of the control plane; the agent says it in the KB language ([Conversation
language](#conversation-language)).
2. **The stack's human documentation is written in English** - `README.md`, `INSTALL.md` and
everything under `docs/` - whatever an instance's `language:` says. It is the
same text in every instance, and the parts of it that are generated come from English
sources, so there is no instance whose language it could follow. `CHANGES.md` is a log, not
documentation, and is not covered.
Nothing checks any of the three mechanically - a stop-word scan would flag the quoted vocabulary
above and miss a translated paragraph that reads cleanly. They are held up by whoever writes the
Nothing checks either mechanically - a stop-word scan would flag the quoted vocabulary above
and miss a translated paragraph that reads cleanly. They are held up by whoever writes the
text, which is why [instructions/CONTRACT.md](instructions/CONTRACT.md) § "Writing an
instruction" names the first two at the step where that happens.
instruction" names the first one at the step where that happens.
**`docs/` carries no normative sentence.** It holds the stack's human documentation - tutorials,
how-to guides and reference, and under `docs/explanation/` why the stack is built the way it is:
+2 -1
View File
@@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
---
## 10.0.0-beta.8 - 2026-10-11 - raw fetch hinter einem Login: raw login speichert die Session des Nutzers als Secret, raw fetch holt damit; gerenderte Seiten über die optionale Browser-Komponente, externe Abrufer pro Host
## 10.0.0-beta.9 - 2026-10-11 - Gesprächssprache: AGENTS.md § Conversation language legt fest, dass der Agent im Chat die KB-Sprache spricht; instructions sync schreibt den Sprachwert als generierte Zeile in jedes veröffentlichte SKILL.md, instructions verify und doctor melden eine geänderte Sprache als Drift
**Author:** Torben Nehmer
@@ -32,6 +32,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
- Site-Handler der Instanz als JSON-Dateien in sites/, nach denselben Bausteinen wie die des Stacks - die nun ebenfalls Datendateien sind
- raw fetch --scope: eine Seite und die Unterseiten, die ihr Site-Handler anbietet, als ein Bündel in incoming/
- raw fetch hinter einem Login: raw login speichert die Session des Nutzers als Secret, raw fetch holt damit; gerenderte Seiten über die optionale Browser-Komponente, externe Abrufer pro Host
- Gesprächssprache: AGENTS.md § Conversation language legt fest, dass der Agent im Chat die KB-Sprache spricht; instructions sync schreibt den Sprachwert als generierte Zeile in jedes veröffentlichte SKILL.md, instructions verify und doctor melden eine geänderte Sprache als Drift
**Low impact**
- Ketten von Paketen: stack-dev schreibt ## Kette, stack-close nennt das nächste Glied
+1 -1
View File
@@ -1 +1 @@
10.0.0-beta.8
10.0.0-beta.9
+29 -1
View File
@@ -7,7 +7,9 @@ tells an agent what to do - [AGENTS.md](../../AGENTS.md), every `CONTRACT.md`, e
is the stack's own documentation for humans, for a different reason (§ "The stack's human
documentation").
The rule itself is in [AGENTS.md § File naming](../../AGENTS.md#file-naming). This page holds the
The rules themselves are in [AGENTS.md § File naming](../../AGENTS.md#file-naming) (the language
of a file) and [AGENTS.md § Conversation language](../../AGENTS.md#conversation-language) (the
language of what an agent says). This page holds the
part that is not a rule: why the line runs where it does, why the English half is not a setting,
and which argument for it turned out to be wrong.
@@ -18,6 +20,7 @@ and which argument for it turned out to be wrong.
- [Why the control plane's language is English](#why-the-control-planes-language-is-english)
- [Why it is not a parameter](#why-it-is-not-a-parameter)
- [What the KB language still decides](#what-the-kb-language-still-decides)
- [Why a rule alone did not hold](#why-a-rule-alone-did-not-hold)
- [Where the line runs around a page type](#where-the-line-runs-around-a-page-type)
- [The stack's human documentation](#the-stacks-human-documentation)
- [What would put this back on the table](#what-would-put-this-back-on-the-table)
@@ -105,6 +108,31 @@ So an operator who reads no English gets German pages and German answers from an
English instructions. The English is what the machinery is written in, not what it says back -
with one exception, the stack's documentation for humans (§ "The stack's human documentation").
### Why a rule alone did not hold
For a while the conversation rule was a sentence about files' languages in `AGENTS.md`, and an
instance on German pages still saw an agent that wrote its pages in German and everything else in
English: progress notes between tool calls, the closing report. Pages were right because the
step that writes a page reads `kb/CONVENTIONS.md` first; the chat was wrong because nothing had
put the value in front of the agent by then. Three things stacked up:
- **The rule sat in the wrong section.** A paragraph about the language of files does not read as
a rule about speaking, and it was phrased as a statement of fact rather than an instruction.
It now has a section of its own.
- **The value was one hop away.** `AGENTS.md` ships verbatim to every instance and so cannot
carry the instance's language; it names the file that does. A short ingest has written several
English messages before it opens that file.
- **The context is English.** Instructions, tool output and error text all are, and one short
message from the operator is outweighed by them - the more so for a weaker model.
So the value is also compiled into the published copy of each skill, as a one-line block under
its frontmatter that `wikitool instructions sync` writes: the language tag and a pointer to the
rule, nothing else. The rule stays in `AGENTS.md` and the value in `kb/CONVENTIONS.md`; the block
holds neither as text of its own, only puts the one in front of the task that needs it. It is a
tag, not a language name, because a table of names would be the compiler's opinion about
languages, which `conventions.py` deliberately has none of. A session with no skill is covered by
the `AGENTS.md` section alone.
## Where the line runs around a page type
A page type's contract is where the two languages meet most closely, and it is worth knowing
@@ -138,6 +138,7 @@ tools/wikitool instructions sync
**Behaviour**
- Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and deletes published skills whose source is gone.
- Each published `SKILL.md` is its source plus one generated line directly under the frontmatter: the `language:` of `kb/CONVENTIONS.md` and a pointer to `AGENTS.md` § Conversation language, so the agent has the value in context when the task begins. The frontmatter and every other file are copied unchanged. With no language declared, the `SKILL.md` is copied unchanged too, and the success message says so.
- Both targets are gitignored, so a fresh clone runs this once.
- Re-running repairs a drifted copy: 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).
@@ -186,7 +187,7 @@ tools/wikitool instructions verify
- Flat instructions validate against `types/instruction.schema.yaml`, and each `SKILL.md` carries the frontmatter its harness reads.
- No `SKILL.md` carries a relative markdown link: `sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead (`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link").
- Every published copy is byte-identical to its source. Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout.
- Every published copy is what `sync` would write from its source: byte-identical, except for the generated language line in `SKILL.md` that the current `language:` of `kb/CONVENTIONS.md` calls for. Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout.
- No instruction is left that nothing references; one marked `manual: true` must instead not be linked from AGENTS.md, CLAUDE.md or a skill.
- Nothing under `instructions/dev/` is referenced from outside it; a `<!-- dist:strip-start/end -->` block is exempt (`instructions/CONTRACT.md`).
- Read-only.
@@ -204,7 +205,7 @@ tools/wikitool instructions verify
- `0` - success
- `1` - Nothing found under `instructions/` at all, or a malformed instruction or `SKILL.md`
- `1` - A `SKILL.md` carries a relative markdown link
- `1` - A published copy drifted from its source
- `1` - A published copy drifted from its source - including a language line that belongs to an earlier `language:` (the message names it as such)
- `1` - An instruction nothing references, or a `manual: true` one that IS linked from AGENTS.md, CLAUDE.md or a skill and so risks running implicitly
- `1` - Something under `instructions/dev/` is referenced from outside it and outside a `dist:strip` block
+23 -10
View File
@@ -160,7 +160,19 @@ Both targets are generated and gitignored. A fresh clone therefore has no skills
Copies, not symlinks: a symlink cannot go stale but is unreliable on Windows checkouts and
does not survive being archived or copied. The price of a copy is drift, and drift is what
`tools/wikitool instructions verify` checks - byte for byte against the source.
`tools/wikitool instructions verify` checks - against what `sync` would write from the source,
which is the source byte for byte plus one generated line.
That line is the instance's conversation language. When `kb/CONVENTIONS.md` declares a
`language:`, `sync` writes `> **Conversation language: \`de\`** - see \`AGENTS.md\` §
Conversation language ...` as the first paragraph under each published `SKILL.md`'s frontmatter,
so the value is in context when the task starts, not a file read away (the rule is
[AGENTS.md § Conversation language](../AGENTS.md#conversation-language); the line carries the
value and a pointer to it, not a second copy of the rule). Only `SKILL.md` gets it, in both
target directories, and its frontmatter and every other file of the skill stay byte-identical. With no
language declared the copy is the source unchanged. A skill's source never contains the line, and
changing `language:` makes `verify` and `doctor` report the copies as drifted, naming the line as
the reason; re-running `sync` repairs it.
## Writing an instruction
@@ -191,12 +203,13 @@ Scaffold with `tools/wikitool new instruction --name "<name>"`; the contract is
`instructions/migrations/` belong to the release they shipped with and are not rewritten. The
stack's own instructions are held to this by a test in the origin repository; what an instance
writes for itself is its own decision.
- **Write it in English, and let the agent speak the instance's language.** Both rules, and the
line between prose and quoted vocabulary, are stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming). They are named here because this is the
step where they are obeyed or lost: nothing checks either mechanically, and an instruction
that models a sentence for the user is where the two are easiest to confuse - the model is
written in English, the saying of it follows `kb/CONVENTIONS.md`'s `language:`.
- **Write it in English, and let the agent speak the instance's language.** The first rule, and
the line between prose and quoted vocabulary, are stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming), the second in
[AGENTS.md § Conversation language](../AGENTS.md#conversation-language). They are named here
because this is the step where they are obeyed or lost: nothing checks either mechanically, and
an instruction that models a sentence for the user is where the two are easiest to confuse -
the model is written in English, the saying of it follows `kb/CONVENTIONS.md`'s `language:`.
### A skill's H1 is a name, not an imperative
@@ -251,9 +264,9 @@ the next session to read before adding one.
### A skill's outbound reference is a plain path, not a link
`tools/wikitool instructions sync` copies each `SKILL.md` byte for byte into
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above) - a different depth
than the source, and without the sibling files a relative link might expect. A markdown link
`tools/wikitool instructions sync` copies each `SKILL.md` into
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above; the generated
language line it adds is no link) - a different depth than the source, and without the sibling files a relative link might expect. A markdown link
correct at `instructions/<name>/SKILL.md` (`../session-setup.md`, `../../kb/CONTRACT.md`)
resolves to a different, usually nonexistent, file once copied: the number of `../` segments
that reaches a target from `instructions/` does not reach the same target from
+2 -2
View File
@@ -72,7 +72,7 @@ session, or a fresh one after `/clear` - assume the second, and work from the bo
`instructions/dev/doc-pull-through.md` has the table of which document that is, per surface,
and its step 3 for the one part that is not prose: a reference file whose headings moved needs
`tools/wikitool docs toc --apply`, never a hand-written list. Prose you write here is English,
whatever language the session is held in - `AGENTS.md` § File naming has both language rules.
whatever language the session is held in - `AGENTS.md` § File naming has the rule for files, § Conversation language the one for what you say.
6. **Publish and wait for CI** per `instructions/dev/publish-and-ci.md`: the local checks,
`tools/wikitool publish`, then the run on the published commit.
@@ -94,7 +94,7 @@ session, or a fresh one after `/clear` - assume the second, and work from the bo
session on Opus at high effort, which works from the body and the diff. That is why the
three fixed points above are not optional.
Close with the fixed line, in the instance's KB language per `AGENTS.md` § File naming:
Close with the fixed line, in the instance's KB language per `AGENTS.md` § Conversation language:
> #N is published and CI is green (run <id>). Next: `/stack-close` - here, or after `/clear`.
+1 -1
View File
@@ -115,7 +115,7 @@ always will.
- **No `## Kette` section:** give no line, as before.
Close the phase with exactly one of these fixed lines, in the instance's KB language per
`AGENTS.md` § File naming. A link is *ready* when it carries `kind/build` and neither
`AGENTS.md` § Conversation language. A link is *ready* when it carries `kind/build` and neither
`status/incoming` nor `status/unconfirmed`:
> #N is closed. Next in the chain "<name>": #M - `/stack-build #M`, here or after `/clear`.
+1 -1
View File
@@ -66,7 +66,7 @@ are split that way.
- **A short one** - an existing spec refreshed against the tree: continue in this session
with `/stack-build #N`.
Close with the fixed line, in the instance's KB language per `AGENTS.md` § File naming:
Close with the fixed line, in the instance's KB language per `AGENTS.md` § Conversation language:
> #N is ready. Next: `/stack-build #N` - here, or after `/clear`.
+4 -3
View File
@@ -51,9 +51,10 @@ the control plane and stays English - the same prose/identifier cut
[kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a page, applied one level up.
`raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)).
Two things follow from that value rather than being decided here, both stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
instance writes its pages in, and an agent *speaks* the language named above.
Two things follow from that value rather than being decided here: the control plane stays English
whatever an instance writes its pages in ([AGENTS.md § File naming](../AGENTS.md#file-naming)),
and an agent *speaks* the language named above
([AGENTS.md § Conversation language](../AGENTS.md#conversation-language)).
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
above is the only language value in the tree, and what it binds is page text; a control-plane
+4 -3
View File
@@ -51,9 +51,10 @@ page, applied one level up. Adopting this template into a non-English instance t
translating those blocks, not the whole file. `raw/` is untouched
([raw/CONTRACT.md](../raw/CONTRACT.md)).
Two things follow from that value rather than being decided here, both stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
instance writes its pages in, and an agent *speaks* the language named above.
Two things follow from that value rather than being decided here: the control plane stays English
whatever an instance writes its pages in ([AGENTS.md § File naming](../AGENTS.md#file-naming)),
and an agent *speaks* the language named above
([AGENTS.md § Conversation language](../AGENTS.md#conversation-language)).
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
above is the only language value in the tree, and what it binds is page text; a control-plane
+4 -3
View File
@@ -2542,6 +2542,7 @@ Publish every `instructions/<name>/SKILL.md` into the harness skill directories.
**NOTES**
- Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and deletes published skills whose source is gone.
- Each published `SKILL.md` is its source plus one generated line directly under the frontmatter: the `language:` of `kb/CONVENTIONS.md` and a pointer to `AGENTS.md` § Conversation language, so the agent has the value in context when the task begins. The frontmatter and every other file are copied unchanged. With no language declared, the `SKILL.md` is copied unchanged too, and the success message says so.
- Both targets are gitignored, so a fresh clone runs this once.
- Re-running repairs a drifted copy: 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).
@@ -2577,7 +2578,7 @@ Check the instruction layer.
- 0 success
- 1 Nothing found under `instructions/` at all, or a malformed instruction or `SKILL.md`
- 1 A `SKILL.md` carries a relative markdown link
- 1 A published copy drifted from its source
- 1 A published copy drifted from its source - including a language line that belongs to an earlier `language:` (the message names it as such)
- 1 An instruction nothing references, or a `manual: true` one that IS linked from AGENTS.md, CLAUDE.md or a skill and so risks running implicitly
- 1 Something under `instructions/dev/` is referenced from outside it and outside a `dist:strip` block
@@ -2585,7 +2586,7 @@ Check the instruction layer.
- Nothing found under `instructions/` at all, or a malformed instruction or `SKILL.md` -> Fix the flagged file, then re-run
- A `SKILL.md` carries a relative markdown link -> Rewrite it as a repo-root-relative plain path, then re-run
- A published copy drifted from its source -> Re-run `instructions sync` - the source under `instructions/` always wins
- A published copy drifted from its source - including a language line that belongs to an earlier `language:` (the message names it as such) -> Re-run `instructions sync` - the source under `instructions/` always wins
- An instruction nothing references, or a `manual: true` one that IS linked from AGENTS.md, CLAUDE.md or a skill and so risks running implicitly -> Link it from where it is used, or drop the link to a manual one, then re-run
- Something under `instructions/dev/` is referenced from outside it and outside a `dist:strip` block -> Remove the reference or wrap it in a `dist:strip` block, then re-run
@@ -2597,7 +2598,7 @@ Check the instruction layer.
- Flat instructions validate against `types/instruction.schema.yaml`, and each `SKILL.md` carries the frontmatter its harness reads.
- No `SKILL.md` carries a relative markdown link: `sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead (`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link").
- Every published copy is byte-identical to its source. Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout.
- Every published copy is what `sync` would write from its source: byte-identical, except for the generated language line in `SKILL.md` that the current `language:` of `kb/CONVENTIONS.md` calls for. Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout.
- No instruction is left that nothing references; one marked `manual: true` must instead not be linked from AGENTS.md, CLAUDE.md or a skill.
- Nothing under `instructions/dev/` is referenced from outside it; a `<!-- dist:strip-start/end -->` block is exempt (`instructions/CONTRACT.md`).
- Read-only.
+2 -1
View File
@@ -288,7 +288,8 @@ def check_skills() -> Check:
)
if drifted:
return Check(
"skills", "FAIL", f"{len(drifted)} published copy/copies drifted from source",
"skills", "FAIL",
f"{len(drifted)} published copy/copies drifted from source, first: {drifted[0]}",
"Run `tools/wikitool instructions sync`",
)
return Check("skills", "OK", f"{expected} published copy/copies match their source")
+95 -14
View File
@@ -12,9 +12,17 @@ nothing else). This reverses an earlier design that used relative symlinks. The
symlink argument was that a link cannot go stale; the counter-arguments that
won are that symlinks are unreliable on Windows checkouts and do not survive
being archived or copied, and that this repo is meant to stay reproducible
elsewhere. The price of a copy is drift, so `verify` checks every copy byte for
byte against its source - the copy is derived truth, and derived truth is either
checked or absent.
elsewhere. The price of a copy is drift, so `verify` checks every copy against
what `sync` would write from its source - the copy is derived truth, and derived
truth is either checked or absent.
The copy is byte-identical to its source except for one generated block: when
`kb/CONVENTIONS.md` declares a `language:`, `sync` inserts a line carrying that
tag directly under each `SKILL.md`'s frontmatter, so the value that decides what
language the agent speaks is in context when the task begins instead of one file
read away (AGENTS.md § Conversation language holds the rule; the block holds
only the value and a pointer to it). `render_skill_md` is the one place that text
is made, and both `sync` and `drift` use it.
Both target directories are gitignored. A fresh clone has no skills until `sync`
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
@@ -44,7 +52,7 @@ from pathlib import Path
import typer
import yaml
from chemenu import cli_contract, config, markdown_code
from chemenu import cli_contract, config, conventions, markdown_code
from chemenu.commands import dist_cmd, docs_verify
from chemenu.commands._util import fail, rel_path, success
from chemenu.type_resolver import resolver
@@ -142,7 +150,66 @@ def _read_frontmatter(path: Path) -> tuple[dict | None, str]:
return frontmatter, ""
def _copy_skill(source: Path, target: Path, force: bool) -> None:
# The closing line of the frontmatter, matched line-wise: `_read_frontmatter`'s
# `find("---")` would stop at a `---` inside a value.
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n.*?\r?\n---[ \t]*(\r?\n|\Z)", re.DOTALL)
_LANGUAGE_BLOCK_TAG_RE = re.compile(r"^> \*\*Conversation language: `([^`\n]+)`\*\*", re.MULTILINE)
def language_block(language: str) -> str:
"""The generated line `sync` writes under a published skill's frontmatter.
A tag, never a language name: a name table would be the compiler's opinion
about languages, which `conventions.py` deliberately has none of."""
return (
f"> **Conversation language: `{language}`** - see `AGENTS.md` § Conversation language "
"(value from `kb/CONVENTIONS.md` `language:`, written by `wikitool instructions sync`)."
)
def render_skill_md(source_text: str, language: str | None) -> str:
"""What the published `SKILL.md` holds for this source and language: the
source itself when no language is declared (or it has no closed
frontmatter to insert under), else the source with `language_block` as the
first paragraph after the frontmatter."""
if not language:
return source_text
match = _FRONTMATTER_RE.match(source_text)
if match is None:
return source_text
end = match.end()
eol = "\r\n" if match.group(0).endswith("\r\n") else "\n"
head = source_text[:end] if match.group(1) else source_text + eol
return f"{head}{eol}{language_block(language)}{eol}{source_text[end:]}"
def _write_skill_md(target: Path, language: str | None) -> None:
path = target / SKILL_FILE
source_text = path.read_bytes().decode("utf-8")
rendered = render_skill_md(source_text, language)
if rendered != source_text:
path.write_bytes(rendered.encode("utf-8"))
def _skill_md_drift(source: Path, target: Path, language: str | None) -> str | None:
"""How a published `SKILL.md` differs from what `sync` would write, or None.
A difference that is only the language block says so: `language:` changed
since the last `sync`, which is not a hand edit and should not read as one."""
source_text = (source / SKILL_FILE).read_bytes().decode("utf-8")
target_text = (target / SKILL_FILE).read_bytes().decode("utf-8")
if target_text == render_skill_md(source_text, language):
return None
found = _LANGUAGE_BLOCK_TAG_RE.search(target_text)
stated = f"conventions say `{language}`" if language else "conventions declare no language"
if found and target_text == render_skill_md(source_text, found.group(1)):
return f"language block is for `{found.group(1)}`, {stated}"
if language and target_text == source_text:
return f"language block missing, {stated}"
return f"differs in {SKILL_FILE}"
def _copy_skill(source: Path, target: Path, force: bool, language: str | None) -> None:
if target.is_symlink():
# Left over from the symlink-based mirror this replaced.
target.unlink()
@@ -155,6 +222,7 @@ def _copy_skill(source: Path, target: Path, force: bool) -> None:
)
shutil.rmtree(target)
shutil.copytree(source, target)
_write_skill_md(target, language)
def _looks_like_a_published_skill(target: Path) -> bool:
@@ -176,13 +244,16 @@ def drift(source: Path, target: Path) -> str | None:
if target.is_symlink():
return "is a symlink, not a copy"
comparison = filecmp.dircmp(str(source), str(target))
if comparison.diff_files:
return f"differs in {', '.join(sorted(comparison.diff_files))}"
# SKILL.md is the one file that is not a byte copy; it is judged against its
# rendering below.
differing = sorted(set(comparison.diff_files) - {SKILL_FILE})
if differing:
return f"differs in {', '.join(differing)}"
if comparison.left_only:
return f"missing {', '.join(sorted(comparison.left_only))}"
if comparison.right_only:
return f"has extra {', '.join(sorted(comparison.right_only))}"
return None
return _skill_md_drift(source, target, conventions.language())
def _reference_haystacks(
@@ -381,6 +452,11 @@ def check_skill_reference_paths() -> list[str]:
notes=(
"Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and "
"`.claude/skills/` as **copies**, and deletes published skills whose source is gone.",
"Each published `SKILL.md` is its source plus one generated line directly under the "
"frontmatter: the `language:` of `kb/CONVENTIONS.md` and a pointer to `AGENTS.md` § "
"Conversation language, so the agent has the value in context when the task begins. The "
"frontmatter and every other file are copied unchanged. With no language declared, the "
"`SKILL.md` is copied unchanged too, and the success message says so.",
"Both targets are gitignored, so a fresh clone runs this once.",
"Re-running repairs a drifted copy: the source always wins.",
"`--force` is required only to replace a target directory that is not a published "
@@ -426,12 +502,13 @@ def sync(
fail(f"No skills found under {rel_path(config.INSTRUCTIONS_DIR)}.")
names = {source.name for source in sources}
language = conventions.language()
published = []
removed = []
for target_root in target_dirs():
target_root.mkdir(parents=True, exist_ok=True)
for source in sources:
_copy_skill(source, target_root / source.name, force)
_copy_skill(source, target_root / source.name, force, language)
# A skill that no longer exists must not keep being offered.
for stale in sorted(target_root.iterdir()):
if stale.name not in names:
@@ -443,7 +520,8 @@ def sync(
published.append(rel_path(target_root))
suffix = f", removed {len(removed)} stale" if removed else ""
success(f"Published {len(sources)} skill(s) to {' and '.join(published)}{suffix}")
stamped = f"conversation language `{language}`" if language else "no language declared"
success(f"Published {len(sources)} skill(s) to {' and '.join(published)} ({stamped}){suffix}")
@app.command("verify")
@@ -464,8 +542,10 @@ def sync(
"than the source, so a `SKILL.md` references a target as a repo-root-relative plain "
"path instead (`instructions/CONTRACT.md` § \"A skill's outbound reference is a plain "
"path, not a link\").",
"Every published copy is byte-identical to its source. Missing *every* copy is "
"reported as \"run sync\", not as drift - that is a clean checkout.",
"Every published copy is what `sync` would write from its source: byte-identical, except "
"for the generated language line in `SKILL.md` that the current `language:` of "
"`kb/CONVENTIONS.md` calls for. Missing *every* copy is reported as \"run sync\", not as "
"drift - that is a clean checkout.",
"No instruction is left that nothing references; one marked `manual: true` must "
"instead not be linked from AGENTS.md, CLAUDE.md or a skill.",
"Nothing under `instructions/dev/` is referenced from outside it; a "
@@ -483,7 +563,8 @@ def sync(
reaction="Rewrite it as a repo-root-relative plain path, then re-run",
),
cli_contract.Failure(
cause="A published copy drifted from its source",
cause="A published copy drifted from its source - including a language line that "
"belongs to an earlier `language:` (the message names it as such)",
reaction="Re-run `instructions sync` - the source under `instructions/` always wins",
),
cli_contract.Failure(
@@ -510,7 +591,7 @@ def sync(
),
))
def verify():
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against what sync would write from its source."""
sources = skill_dirs()
instructions = instruction_files()
if not sources and not instructions:
+16
View File
@@ -461,6 +461,22 @@ def test_unpublished_skills_fail(instance):
assert _status(checks, "skills") == "FAIL"
def test_a_changed_conversation_language_fails_skills_and_names_the_block(instance):
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
"---\nlanguage: de\nprofile: none\nsections:\n links: Relationships\n"
" footnotes: Footnotes\n---\n\n# conventions\n",
encoding="utf-8",
)
conventions.reset_cache()
checks = doctor.run_doctor()
assert _status(checks, "skills") == "FAIL"
assert "2 published copy/copies drifted" in _detail(checks, "skills")
assert "language block is for `en`, conventions say `de`" in _detail(checks, "skills")
instructions_cmd.sync(force=False)
assert _status(doctor.run_doctor(), "skills") == "OK"
def test_no_author_fails(instance, monkeypatch):
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
monkeypatch.setattr(config, "default_author", lambda: None)
+179 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
import typer
from chemenu import config
from chemenu import config, conventions
from chemenu.tests.conftest import use_shipped_type_specs
from chemenu.commands import instructions_cmd
@@ -230,6 +230,184 @@ def test_drift_detects_missing_edited_and_extra(layer):
assert "extra" in instructions_cmd.drift(source, target)
# --- conversation language block --------------------------------------------
def _declare_language(root: Path, tag: str | None) -> None:
path = root / "kb" / "CONVENTIONS.md"
if tag is None:
path.unlink(missing_ok=True)
else:
path.write_text(f"---\nlanguage: {tag}\nprofile: none\n---\n\n# conventions\n", encoding="utf-8")
conventions.reset_cache()
def _rich_skill(layer) -> Path:
"""A skill whose frontmatter has a flag and a value holding `---`, plus a sibling file."""
source = layer / "instructions" / "wiki-demo"
(source / "SKILL.md").write_text(
"---\nname: wiki-demo\ndescription: Demo --- skill.\ndisable-model-invocation: true\n---\n\n# Demo\n\nSee gates.md.\n",
encoding="utf-8",
)
(source / "reference.md").write_text("# reference\n", encoding="utf-8")
return source
def _block_lines(text: str) -> list[str]:
return [line for line in text.splitlines() if line.startswith("> **Conversation language:")]
def test_sync_writes_the_language_block_directly_under_the_frontmatter_in_both_targets(layer):
source = _rich_skill(layer)
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
source_text = (source / "SKILL.md").read_text(encoding="utf-8")
frontmatter_end = source_text.index("---\n\n# Demo") + len("---\n")
for harness in (".agents", ".claude"):
copy = _skill_copy(layer, harness)
text = (copy / "SKILL.md").read_text(encoding="utf-8")
assert len(_block_lines(text)) == 1
assert text[:frontmatter_end] == source_text[:frontmatter_end]
after = text[frontmatter_end:].split("\n")
assert after[0] == "" and after[1].startswith("> **Conversation language: `de`**")
assert "`AGENTS.md` § Conversation language" in after[1]
assert text.replace(after[1] + "\n\n", "", 1) == source_text
assert (copy / "reference.md").read_bytes() == (source / "reference.md").read_bytes()
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) is None
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) is None
def test_the_block_is_the_tag_and_never_a_language_name(layer):
_declare_language(layer, "pt-BR")
instructions_cmd.sync(force=False)
text = (_skill_copy(layer, ".claude") / "SKILL.md").read_text(encoding="utf-8")
assert "`pt-BR`" in _block_lines(text)[0]
assert "Portuguese" not in text
def test_a_second_sync_is_byte_identical_and_adds_no_second_block(layer):
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
first = (_skill_copy(layer, ".agents") / "SKILL.md").read_bytes()
instructions_cmd.sync(force=False)
second = (_skill_copy(layer, ".agents") / "SKILL.md").read_bytes()
assert first == second
assert len(_block_lines(second.decode("utf-8"))) == 1
def test_without_a_declared_language_every_copy_is_the_source(layer, capsys):
source = _rich_skill(layer)
_declare_language(layer, None)
instructions_cmd.sync(force=False)
assert "no language declared" in " ".join(capsys.readouterr().out.split())
for harness in (".agents", ".claude"):
copy = _skill_copy(layer, harness)
assert (copy / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes()
assert instructions_cmd.drift(source, copy) is None
def test_sync_names_the_language_it_stamped(layer, capsys):
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
assert "conversation language `de`" in " ".join(capsys.readouterr().out.split())
def test_a_skill_without_closed_frontmatter_is_copied_unchanged(layer):
source = layer / "instructions" / "wiki-demo"
(source / "SKILL.md").write_text("---\nname: wiki-demo\n# never closed\n", encoding="utf-8")
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
assert (_skill_copy(layer, ".claude") / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes()
def test_a_crlf_skill_keeps_its_line_endings(layer):
source = layer / "instructions" / "wiki-demo"
(source / "SKILL.md").write_bytes(b"---\r\nname: wiki-demo\r\ndescription: Demo.\r\n---\r\n\r\n# Demo\r\n")
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
data = (_skill_copy(layer, ".claude") / "SKILL.md").read_bytes()
assert data.count(b"\n") == data.count(b"\r\n")
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) is None
def test_changing_the_language_after_sync_is_drift_that_names_the_block(layer):
source = layer / "instructions" / "wiki-demo"
_declare_language(layer, "en")
instructions_cmd.sync(force=False)
_declare_language(layer, "de")
for harness in (".agents", ".claude"):
assert instructions_cmd.drift(source, _skill_copy(layer, harness)) == (
"language block is for `en`, conventions say `de`"
)
instructions_cmd.sync(force=False)
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) is None
def test_declaring_a_language_after_a_plain_sync_is_drift_that_names_the_block(layer):
source = layer / "instructions" / "wiki-demo"
instructions_cmd.sync(force=False)
_declare_language(layer, "de")
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == (
"language block missing, conventions say `de`"
)
def test_dropping_the_language_after_sync_is_drift_that_names_the_block(layer):
source = layer / "instructions" / "wiki-demo"
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
_declare_language(layer, None)
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == (
"language block is for `de`, conventions declare no language"
)
def test_a_hand_edit_next_to_the_block_is_still_reported_as_a_difference(layer):
source = layer / "instructions" / "wiki-demo"
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
target = _skill_copy(layer, ".agents") / "SKILL.md"
target.write_text(target.read_text(encoding="utf-8") + "extra\n", encoding="utf-8")
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == "differs in SKILL.md"
def test_a_hand_edit_of_another_file_is_still_reported(layer):
source = _rich_skill(layer)
_declare_language(layer, "de")
instructions_cmd.sync(force=False)
(_skill_copy(layer, ".claude") / "reference.md").write_text("# edited\n", encoding="utf-8")
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) == "differs in reference.md"
def test_verify_reports_a_language_change_per_copy_and_sync_repairs_it(layer, capsys):
_declare_language(layer, "en")
instructions_cmd.sync(force=False)
instructions_cmd.verify()
capsys.readouterr()
_declare_language(layer, "de")
with pytest.raises(typer.Exit):
instructions_cmd.verify()
out = capsys.readouterr().out
for harness in (".agents", ".claude"):
assert f"{harness}/skills/wiki-demo: language block is for `en`, conventions say `de`" in out
instructions_cmd.sync(force=False)
instructions_cmd.verify()
assert "match their source" in " ".join(capsys.readouterr().out.split())
def test_every_real_skill_takes_the_block():
"""A source whose frontmatter the insertion cannot find would be published
without the language and nothing would say so."""
for source in instructions_cmd.skill_dirs():
text = (source / "SKILL.md").read_text(encoding="utf-8")
rendered = instructions_cmd.render_skill_md(text, "de")
assert rendered != text, source.name
assert len(_block_lines(rendered)) == 1, source.name
# --- verify -----------------------------------------------------------------