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
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:
1 parent
c06bde2e95
commit
0f0a5799fd
16 files changed
+385
-54
No files matched your search
@@ -13,6 +13,7 @@ maintained permanently; anything mechanical is done by `tools/wikitool`, never b
|
|||||||
|
|
||||||
- [Bootstrap](#bootstrap)
|
- [Bootstrap](#bootstrap)
|
||||||
- [Invariants](#invariants)
|
- [Invariants](#invariants)
|
||||||
|
- [Conversation language](#conversation-language)
|
||||||
- [File naming](#file-naming)
|
- [File naming](#file-naming)
|
||||||
- [Personalization](#personalization)
|
- [Personalization](#personalization)
|
||||||
- [Environment](#environment)
|
- [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
|
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.
|
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
|
## File naming
|
||||||
|
|
||||||
What a file is called says who it is for and how it is loaded. This is a rule, not a habit;
|
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
|
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
||||||
happened once: no README may hold a copy of the `wikitool` command table.
|
happened once: no README may hold a copy of the `wikitool` command table.
|
||||||
|
|
||||||
**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
|
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`,
|
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
|
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;
|
moves this rule;
|
||||||
[docs/explanation/language-boundaries.md](docs/explanation/language-boundaries.md) has the
|
[docs/explanation/language-boundaries.md](docs/explanation/language-boundaries.md) has the
|
||||||
reasoning.
|
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
|
An instruction that models a sentence for the user writes that model in English like the rest
|
||||||
sentence for the user writes it in English like the rest of the control plane, and the agent
|
of the control plane; the agent says it in the KB language ([Conversation
|
||||||
says it in that language.
|
language](#conversation-language)).
|
||||||
3. **The stack's human documentation is written in English** - `README.md`, `INSTALL.md` and
|
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
|
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
|
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
|
sources, so there is no instance whose language it could follow. `CHANGES.md` is a log, not
|
||||||
documentation, and is not covered.
|
documentation, and is not covered.
|
||||||
|
|
||||||
Nothing checks any of the three mechanically - a stop-word scan would flag the quoted vocabulary
|
Nothing checks either mechanically - a stop-word scan would flag the quoted vocabulary above
|
||||||
above and miss a translated paragraph that reads cleanly. They are held up by whoever writes the
|
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
|
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,
|
**`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:
|
how-to guides and reference, and under `docs/explanation/` why the stack is built the way it is:
|
||||||
|
|||||||
+2
-1
@@ -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
|
**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
|
- 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 --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
|
- 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**
|
**Low impact**
|
||||||
- Ketten von Paketen: stack-dev schreibt ## Kette, stack-close nennt das nächste Glied
|
- Ketten von Paketen: stack-dev schreibt ## Kette, stack-close nennt das nächste Glied
|
||||||
|
|||||||
@@ -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
|
is the stack's own documentation for humans, for a different reason (§ "The stack's human
|
||||||
documentation").
|
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,
|
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.
|
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 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)
|
- [Why it is not a parameter](#why-it-is-not-a-parameter)
|
||||||
- [What the KB language still decides](#what-the-kb-language-still-decides)
|
- [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)
|
- [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)
|
- [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)
|
- [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 -
|
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").
|
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
|
## 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
|
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**
|
**Behaviour**
|
||||||
|
|
||||||
- Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and deletes published skills whose source is gone.
|
- 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.
|
- Both targets are gitignored, so a fresh clone runs this once.
|
||||||
- Re-running repairs a drifted copy: the source always wins.
|
- 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).
|
- `--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.
|
- 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").
|
- 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.
|
- 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`).
|
- Nothing under `instructions/dev/` is referenced from outside it; a `<!-- dist:strip-start/end -->` block is exempt (`instructions/CONTRACT.md`).
|
||||||
- Read-only.
|
- Read-only.
|
||||||
@@ -204,7 +205,7 @@ tools/wikitool instructions verify
|
|||||||
- `0` - success
|
- `0` - success
|
||||||
- `1` - Nothing found under `instructions/` at all, or a malformed instruction or `SKILL.md`
|
- `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 `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` - 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
|
- `1` - Something under `instructions/dev/` is referenced from outside it and outside a `dist:strip` block
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -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
|
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
|
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
|
## 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
|
`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
|
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.
|
writes for itself is its own decision.
|
||||||
- **Write it in English, and let the agent speak the instance's language.** Both rules, and the
|
- **Write it in English, and let the agent speak the instance's language.** The first rule, and
|
||||||
line between prose and quoted vocabulary, are stated once in
|
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
|
[AGENTS.md § File naming](../AGENTS.md#file-naming), the second in
|
||||||
step where they are obeyed or lost: nothing checks either mechanically, and an instruction
|
[AGENTS.md § Conversation language](../AGENTS.md#conversation-language). They are named here
|
||||||
that models a sentence for the user is where the two are easiest to confuse - the model is
|
because this is the step where they are obeyed or lost: nothing checks either mechanically, and
|
||||||
written in English, the saying of it follows `kb/CONVENTIONS.md`'s `language:`.
|
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
|
### 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
|
### A skill's outbound reference is a plain path, not a link
|
||||||
|
|
||||||
`tools/wikitool instructions sync` copies each `SKILL.md` byte for byte into
|
`tools/wikitool instructions sync` copies each `SKILL.md` into
|
||||||
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above) - a different depth
|
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above; the generated
|
||||||
than the source, and without the sibling files a relative link might expect. A markdown link
|
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`)
|
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
|
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
|
that reaches a target from `instructions/` does not reach the same target from
|
||||||
|
|||||||
@@ -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,
|
`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
|
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,
|
`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,
|
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.
|
`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
|
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.
|
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`.
|
> #N is published and CI is green (run <id>). Next: `/stack-close` - here, or after `/clear`.
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ always will.
|
|||||||
- **No `## Kette` section:** give no line, as before.
|
- **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
|
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`:
|
`status/incoming` nor `status/unconfirmed`:
|
||||||
|
|
||||||
> #N is closed. Next in the chain "<name>": #M - `/stack-build #M`, here or after `/clear`.
|
> #N is closed. Next in the chain "<name>": #M - `/stack-build #M`, here or after `/clear`.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ are split that way.
|
|||||||
- **A short one** - an existing spec refreshed against the tree: continue in this session
|
- **A short one** - an existing spec refreshed against the tree: continue in this session
|
||||||
with `/stack-build #N`.
|
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`.
|
> #N is ready. Next: `/stack-build #N` - here, or after `/clear`.
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -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.
|
[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)).
|
`raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)).
|
||||||
|
|
||||||
Two things follow from that value rather than being decided here, both stated once in
|
Two things follow from that value rather than being decided here: the control plane stays English
|
||||||
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
|
whatever an instance writes its pages in ([AGENTS.md § File naming](../AGENTS.md#file-naming)),
|
||||||
instance writes its pages in, and an agent *speaks* the language named above.
|
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:`
|
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
|
above is the only language value in the tree, and what it binds is page text; a control-plane
|
||||||
|
|||||||
@@ -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
|
translating those blocks, not the whole file. `raw/` is untouched
|
||||||
([raw/CONTRACT.md](../raw/CONTRACT.md)).
|
([raw/CONTRACT.md](../raw/CONTRACT.md)).
|
||||||
|
|
||||||
Two things follow from that value rather than being decided here, both stated once in
|
Two things follow from that value rather than being decided here: the control plane stays English
|
||||||
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
|
whatever an instance writes its pages in ([AGENTS.md § File naming](../AGENTS.md#file-naming)),
|
||||||
instance writes its pages in, and an agent *speaks* the language named above.
|
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:`
|
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
|
above is the only language value in the tree, and what it binds is page text; a control-plane
|
||||||
|
|||||||
+4
-3
@@ -2542,6 +2542,7 @@ Publish every `instructions/<name>/SKILL.md` into the harness skill directories.
|
|||||||
**NOTES**
|
**NOTES**
|
||||||
|
|
||||||
- Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and deletes published skills whose source is gone.
|
- 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.
|
- Both targets are gitignored, so a fresh clone runs this once.
|
||||||
- Re-running repairs a drifted copy: the source always wins.
|
- 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).
|
- `--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
|
- 0 success
|
||||||
- 1 Nothing found under `instructions/` at all, or a malformed instruction or `SKILL.md`
|
- 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 `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 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
|
- 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
|
- 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 `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
|
- 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
|
- 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.
|
- 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").
|
- 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.
|
- 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`).
|
- Nothing under `instructions/dev/` is referenced from outside it; a `<!-- dist:strip-start/end -->` block is exempt (`instructions/CONTRACT.md`).
|
||||||
- Read-only.
|
- Read-only.
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ def check_skills() -> Check:
|
|||||||
)
|
)
|
||||||
if drifted:
|
if drifted:
|
||||||
return Check(
|
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`",
|
"Run `tools/wikitool instructions sync`",
|
||||||
)
|
)
|
||||||
return Check("skills", "OK", f"{expected} published copy/copies match their source")
|
return Check("skills", "OK", f"{expected} published copy/copies match their source")
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
elsewhere. The price of a copy is drift, so `verify` checks every copy against
|
||||||
byte against its source - the copy is derived truth, and derived truth is either
|
what `sync` would write from its source - the copy is derived truth, and derived
|
||||||
checked or absent.
|
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`
|
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
|
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
||||||
@@ -44,7 +52,7 @@ from pathlib import Path
|
|||||||
import typer
|
import typer
|
||||||
import yaml
|
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 import dist_cmd, docs_verify
|
||||||
from chemenu.commands._util import fail, rel_path, success
|
from chemenu.commands._util import fail, rel_path, success
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
@@ -142,7 +150,66 @@ def _read_frontmatter(path: Path) -> tuple[dict | None, str]:
|
|||||||
return frontmatter, ""
|
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():
|
if target.is_symlink():
|
||||||
# Left over from the symlink-based mirror this replaced.
|
# Left over from the symlink-based mirror this replaced.
|
||||||
target.unlink()
|
target.unlink()
|
||||||
@@ -155,6 +222,7 @@ def _copy_skill(source: Path, target: Path, force: bool) -> None:
|
|||||||
)
|
)
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
shutil.copytree(source, target)
|
shutil.copytree(source, target)
|
||||||
|
_write_skill_md(target, language)
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_a_published_skill(target: Path) -> bool:
|
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():
|
if target.is_symlink():
|
||||||
return "is a symlink, not a copy"
|
return "is a symlink, not a copy"
|
||||||
comparison = filecmp.dircmp(str(source), str(target))
|
comparison = filecmp.dircmp(str(source), str(target))
|
||||||
if comparison.diff_files:
|
# SKILL.md is the one file that is not a byte copy; it is judged against its
|
||||||
return f"differs in {', '.join(sorted(comparison.diff_files))}"
|
# rendering below.
|
||||||
|
differing = sorted(set(comparison.diff_files) - {SKILL_FILE})
|
||||||
|
if differing:
|
||||||
|
return f"differs in {', '.join(differing)}"
|
||||||
if comparison.left_only:
|
if comparison.left_only:
|
||||||
return f"missing {', '.join(sorted(comparison.left_only))}"
|
return f"missing {', '.join(sorted(comparison.left_only))}"
|
||||||
if comparison.right_only:
|
if comparison.right_only:
|
||||||
return f"has extra {', '.join(sorted(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(
|
def _reference_haystacks(
|
||||||
@@ -381,6 +452,11 @@ def check_skill_reference_paths() -> list[str]:
|
|||||||
notes=(
|
notes=(
|
||||||
"Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and "
|
"Publishes every `instructions/<name>/SKILL.md` into `.agents/skills/` and "
|
||||||
"`.claude/skills/` as **copies**, and deletes published skills whose source is gone.",
|
"`.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.",
|
"Both targets are gitignored, so a fresh clone runs this once.",
|
||||||
"Re-running repairs a drifted copy: the source always wins.",
|
"Re-running repairs a drifted copy: the source always wins.",
|
||||||
"`--force` is required only to replace a target directory that is not a published "
|
"`--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)}.")
|
fail(f"No skills found under {rel_path(config.INSTRUCTIONS_DIR)}.")
|
||||||
|
|
||||||
names = {source.name for source in sources}
|
names = {source.name for source in sources}
|
||||||
|
language = conventions.language()
|
||||||
published = []
|
published = []
|
||||||
removed = []
|
removed = []
|
||||||
for target_root in target_dirs():
|
for target_root in target_dirs():
|
||||||
target_root.mkdir(parents=True, exist_ok=True)
|
target_root.mkdir(parents=True, exist_ok=True)
|
||||||
for source in sources:
|
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.
|
# A skill that no longer exists must not keep being offered.
|
||||||
for stale in sorted(target_root.iterdir()):
|
for stale in sorted(target_root.iterdir()):
|
||||||
if stale.name not in names:
|
if stale.name not in names:
|
||||||
@@ -443,7 +520,8 @@ def sync(
|
|||||||
published.append(rel_path(target_root))
|
published.append(rel_path(target_root))
|
||||||
|
|
||||||
suffix = f", removed {len(removed)} stale" if removed else ""
|
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")
|
@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 "
|
"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 instead (`instructions/CONTRACT.md` § \"A skill's outbound reference is a plain "
|
||||||
"path, not a link\").",
|
"path, not a link\").",
|
||||||
"Every published copy is byte-identical to its source. Missing *every* copy is "
|
"Every published copy is what `sync` would write from its source: byte-identical, except "
|
||||||
"reported as \"run sync\", not as drift - that is a clean checkout.",
|
"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 "
|
"No instruction is left that nothing references; one marked `manual: true` must "
|
||||||
"instead not be linked from AGENTS.md, CLAUDE.md or a skill.",
|
"instead not be linked from AGENTS.md, CLAUDE.md or a skill.",
|
||||||
"Nothing under `instructions/dev/` is referenced from outside it; a "
|
"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",
|
reaction="Rewrite it as a repo-root-relative plain path, then re-run",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
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",
|
reaction="Re-run `instructions sync` - the source under `instructions/` always wins",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
@@ -510,7 +591,7 @@ def sync(
|
|||||||
),
|
),
|
||||||
))
|
))
|
||||||
def verify():
|
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()
|
sources = skill_dirs()
|
||||||
instructions = instruction_files()
|
instructions = instruction_files()
|
||||||
if not sources and not instructions:
|
if not sources and not instructions:
|
||||||
|
|||||||
@@ -461,6 +461,22 @@ def test_unpublished_skills_fail(instance):
|
|||||||
assert _status(checks, "skills") == "FAIL"
|
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):
|
def test_no_author_fails(instance, monkeypatch):
|
||||||
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
||||||
monkeypatch.setattr(config, "default_author", lambda: None)
|
monkeypatch.setattr(config, "default_author", lambda: None)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import config, conventions
|
||||||
from chemenu.tests.conftest import use_shipped_type_specs
|
from chemenu.tests.conftest import use_shipped_type_specs
|
||||||
from chemenu.commands import instructions_cmd
|
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)
|
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 -----------------------------------------------------------------
|
# --- verify -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user