Files
chemenu/tools/chemenu/commands/instructions_cmd.py
T
torbenandClaude Code 0f0a5799fd
CI / verify (push) Successful in 8m6s
CI / pwsh (push) Successful in 2m50s
Release / release (push) Successful in 33s
Release / wiki-mirror (push) Skipped
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
2026-10-12 06:57:51 +02:00

778 lines
35 KiB
Python

"""`wikitool instructions sync|verify|list` - the instruction layer.
`instructions/` is the single source for everything an agent is told to do. It
holds two forms, told apart structurally rather than by any flag:
- `instructions/<name>.md` - an instruction, reached by link or on request
- `instructions/<name>/SKILL.md` - a skill, published into the harness directories
Publication is by **copy**, into `.agents/skills/` (read natively by GitHub
Copilot, Codex CLI and Mistral Vibe) and `.claude/skills/` (Claude Code reads
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 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
than reporting an error when *every* copy is missing, because that is the
expected state of a clean checkout rather than a fault.
The copy is also a different depth than the source, and without the sibling
files a relative link might expect - a plain `shutil.copytree` per skill
directory, not a mirror of the whole `instructions/` tree. A relative markdown
link correct at `instructions/<name>/SKILL.md` therefore resolves to a
different, usually nonexistent, file in the published copy the harness
actually reads. `verify` forbids the shape outright
(`check_skill_reference_paths`) rather than checking depth arithmetic, and a
`SKILL.md` writes an outbound reference as a repo-root-relative plain path
instead - see instructions/CONTRACT.md § "A skill's outbound reference is a
plain path, not a link". `docs_verify.check_reference_targets` is the
complementary check, over the flat instructions and contracts that are still
allowed to link normally because nothing ever copies them elsewhere.
"""
from __future__ import annotations
import filecmp
import re
import shutil
from pathlib import Path
import typer
import yaml
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
app = typer.Typer(help="Manage instructions/ and publish its skills into the harness directories.")
SKILL_FILE = "SKILL.md"
CONTRACT_FILE = "CONTRACT.md"
INSTRUCTION_TYPE = "types/instruction.md"
# instructions/dev/ is a second, purpose-scoped root nested one level in:
# stack-development-only instructions and (nested one level further) the
# skill that switches a session into that mode. `dist export` prunes it
# wholesale (see dist_cmd.INSTRUCTIONS_EXCLUDE_DIRS) - discovery below treats
# it as a second location to scan, not as ordinary recursion.
DEV_SUBDIR = "dev"
# Root files an agent actually loads, and from which a link therefore *reaches*
# it. CLAUDE.md sits alongside AGENTS.md rather than being folded into one name:
# AGENTS.md is read natively by every other harness (Codex CLI, GitHub Copilot
# CLI, Mistral Vibe), while CLAUDE.md is read only by Claude Code, which does not
# load AGENTS.md on its own (see CLAUDE.md itself, and AGENTS.md's file-naming
# table). A link that belongs in only one harness's auto-loaded file still has to
# count - see automatic_load_paths() below for the matching half of this split.
AGENT_ROOT_FILES = ("AGENTS.md", "CLAUDE.md")
# README.md and CHANGES.md are deliberately NOT above. They describe the stack
# to humans: AGENTS.md's file-naming table defines README.md as "never by an
# agent as instruction", and CHANGES.md is a changelog. An instruction whose only
# mention is in one of them deploys to no one, so counting either as a reference
# would be a false green by construction - `verify` would go on reporting the
# layer healthy while the instruction had become unreachable.
#
# The dev-boundary check asks a different question - what would *dangle* in a
# distributed instance - so it scans README.md too, because `dist export` copies
# it verbatim (dist_cmd.ROOT_FILES). CHANGES.md stays out even there; see
# dev_only_forbidden_references for why.
SHIPPED_DOC_ROOT_FILES = ("README.md",)
def skill_dirs(instructions_dir: Path | None = None) -> list[Path]:
"""Directories under instructions/ (including instructions/dev/) that contain a SKILL.md."""
root = instructions_dir or config.INSTRUCTIONS_DIR
if not root.is_dir():
return []
candidates = list(root.iterdir())
dev_root = root / DEV_SUBDIR
if dev_root.is_dir():
candidates += list(dev_root.iterdir())
return sorted(p for p in candidates if p.is_dir() and (p / SKILL_FILE).exists())
def instruction_files(instructions_dir: Path | None = None) -> list[Path]:
"""Flat instruction files - everything except the layer's own contract.
Includes instructions/dev/*.md, the stack-development-only subset
`dist export` excludes wholesale."""
root = instructions_dir or config.INSTRUCTIONS_DIR
if not root.is_dir():
return []
files = list(root.glob("*.md"))
dev_root = root / DEV_SUBDIR
if dev_root.is_dir():
files += list(dev_root.glob("*.md"))
return sorted(p for p in files if p.name != CONTRACT_FILE)
def is_dev_only(path: Path, instructions_dir: Path | None = None) -> bool:
"""Whether `path` sits under instructions/dev/."""
root = instructions_dir or config.INSTRUCTIONS_DIR
try:
path.relative_to(root / DEV_SUBDIR)
except ValueError:
return False
return True
def target_dirs() -> list[Path]:
return [config.AGENTS_SKILLS_DIR, config.CLAUDE_SKILLS_DIR]
def _read_frontmatter(path: Path) -> tuple[dict | None, str]:
"""Return (frontmatter, error). Exactly one is meaningful."""
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
return None, "has no YAML frontmatter"
end = text.find("---", 3)
if end == -1:
return None, "frontmatter block is not closed with `---`"
try:
frontmatter = yaml.safe_load(text[3:end]) or {}
except yaml.YAMLError as exc:
return None, f"frontmatter is not valid YAML ({exc})"
if not isinstance(frontmatter, dict):
return None, "frontmatter is not a mapping"
return frontmatter, ""
# 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()
elif target.exists():
if not _looks_like_a_published_skill(target) and not force:
fail(
f"{rel_path(target)} is not a published skill (no SKILL.md). Syncing would "
"delete it and everything in it. Check whether it holds anything worth "
"keeping, then re-run with --force."
)
shutil.rmtree(target)
shutil.copytree(source, target)
_write_skill_md(target, language)
def _looks_like_a_published_skill(target: Path) -> bool:
"""Whether this target directory is a skill slot sync owns.
Deliberately a *structural* test, not a comparison against the source. An
edited copy also differs from its source, and re-running `sync` is the
documented fix for exactly that - so refusing on difference would refuse
the repair. What sync must not silently delete is a directory that was
never a published skill at all.
"""
return (target / SKILL_FILE).exists()
def drift(source: Path, target: Path) -> str | None:
"""Describe how a published copy differs from its source, or None."""
if not target.exists():
return "missing"
if target.is_symlink():
return "is a symlink, not a copy"
comparison = filecmp.dircmp(str(source), str(target))
# 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 _skill_md_drift(source, target, conventions.language())
def _reference_haystacks(
root: Path, *, include_dev: bool, root_files: tuple[str, ...]
) -> list[Path]:
"""Every file that could mention an instruction: the given `root_files`,
every CONTRACT.md, every COLLECTION.md, every skill's SKILL.md, every flat
instruction. `include_dev=False` restricts the skill/instruction portion to
files outside instructions/dev/ - what `dev_only_forbidden_references`
needs, since it specifically asks about mentions from outside that
boundary.
`root_files` is the axis the two callers actually differ on, and it is
passed explicitly rather than defaulted because getting it wrong is silent
in both directions: too wide, and a human-only document keeps a dead
instruction looking alive; too narrow, and a reference that would dangle in
a distributed instance goes unreported. See AGENT_ROOT_FILES and
SHIPPED_DOC_ROOT_FILES."""
haystacks: list[Path] = []
for name in root_files:
candidate = config.ROOT / name
if candidate.exists():
haystacks.append(candidate)
haystacks += sorted(config.ROOT.rglob(CONTRACT_FILE))
haystacks += sorted(config.KB_DIR.rglob("COLLECTION.md")) if config.KB_DIR.is_dir() else []
skills = skill_dirs(root)
instructions = instruction_files(root)
if not include_dev:
skills = [d for d in skills if not is_dev_only(d, root)]
instructions = [p for p in instructions if not is_dev_only(p, root)]
haystacks += [d / SKILL_FILE for d in skills]
haystacks += instructions
return haystacks
def referenced_names(instructions_dir: Path | None = None) -> set[str]:
"""Every instruction filename referenced from somewhere that loads it.
Scans only what an agent can actually reach: AGENTS.md/CLAUDE.md, the
contracts and collection files, the skills, and the other instructions.
README.md and CHANGES.md are deliberately not in the haystack - a mention
there documents an instruction to a human without deploying it to anyone.
"""
root = instructions_dir or config.INSTRUCTIONS_DIR
haystacks = _reference_haystacks(root, include_dev=True, root_files=AGENT_ROOT_FILES)
referenced: set[str] = set()
for path in haystacks:
try:
text = path.read_text(encoding="utf-8")
except OSError: # pragma: no cover - unreadable file
continue
for candidate in instruction_files(root):
if candidate == path:
continue # a file referencing itself is not a reference
if candidate.name in text:
referenced.add(candidate.name)
return referenced
def automatic_load_paths(instructions_dir: Path | None = None) -> list[Path]:
"""Where an agent encounters a link *without* asking for it by name:
AGENTS.md (loaded every session by every other harness) and CLAUDE.md
(loaded every session, but only by Claude Code, which does not load
AGENTS.md on its own), plus every published skill's SKILL.md (loaded
once by the harness, then followed as live procedure).
Deliberately narrower than `referenced_names()`'s haystack: a mention in
a CONTRACT.md, a COLLECTION.md, or another instruction's "see also" is
documentation a reader opts into, not something that runs on its own -
this is what `manual: true` (see instructions/CONTRACT.md) checks against.
"""
root = instructions_dir or config.INSTRUCTIONS_DIR
agents_md = config.ROOT / "AGENTS.md"
claude_md = config.ROOT / "CLAUDE.md"
haystacks: list[Path] = [p for p in (agents_md, claude_md) if p.exists()]
haystacks += [d / SKILL_FILE for d in skill_dirs(root)]
return haystacks
def manual_forbidden_references(instructions_dir: Path | None = None) -> set[str]:
"""Instruction filenames mentioned somewhere they would be picked up
automatically - forbidden for a `manual: true` instruction."""
root = instructions_dir or config.INSTRUCTIONS_DIR
referenced: set[str] = set()
for path in automatic_load_paths(root):
try:
text = path.read_text(encoding="utf-8")
except OSError: # pragma: no cover - unreadable file
continue
for candidate in instruction_files(root):
if candidate.name in text:
referenced.add(candidate.name)
return referenced
def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[str]:
"""Instruction/skill names under instructions/dev/ mentioned from outside
it - forbidden. `dist export` prunes instructions/dev/ wholesale
(dist_cmd.INSTRUCTIONS_EXCLUDE_DIRS), so a reference from outside it
would either dangle in a distributed instance or leak a dev-only
procedure into a regular content path.
A mention inside a <!-- dist:strip-start/end --> block is exempt: it is
stripped from the haystack text before the scan (`dist_cmd.strip_markers`,
the same utility `dist export` itself uses), because `dist export` drops
that block and instructions/dev/ together - nothing is left dangling.
The haystack is wider here than in `referenced_names()`: README.md is
scanned too, because this check is about what would *dangle* in a shipped
document rather than about what an agent can reach, and `dist export`
copies README.md verbatim (dist_cmd.ROOT_FILES). CHANGES.md is the one
shipped-looking file left out - `dist export` always replaces it wholesale
with dist_templates/CHANGES.md regardless of its content, so a historical
mention there never reaches a distributed instance in the first place."""
root = instructions_dir or config.INSTRUCTIONS_DIR
dev_names = {p.name for p in instruction_files(root) if is_dev_only(p, root)}
dev_names |= {d.name for d in skill_dirs(root) if is_dev_only(d, root)}
if not dev_names:
return set()
referenced: set[str] = set()
haystacks = _reference_haystacks(
root, include_dev=False, root_files=AGENT_ROOT_FILES + SHIPPED_DOC_ROOT_FILES
)
for path in haystacks:
try:
text = dist_cmd.strip_markers(path.read_text(encoding="utf-8"))
except OSError: # pragma: no cover - unreadable file
continue
for name in dev_names:
# Word-bounded, not a bare substring test: a generated TOC anchor
# like `#where-stack-development-happens` contains "stack-dev"
# as a raw substring without mentioning the skill at all. `\b`
# does not fire between "v" and "e" (both word characters), so
# "stack-development" is correctly not a match while a real
# mention (`` `stack-dev` ``, `instructions/dev/stack-dev/`) -
# bounded by punctuation on both sides - still is.
if re.search(rf"\b{re.escape(name)}\b", text):
referenced.add(name)
return referenced
def check_skill_reference_paths() -> list[str]:
"""No `SKILL.md` may carry a relative markdown link.
`sync` copies each skill directory verbatim into `.agents/skills/<name>/`
and `.claude/skills/<name>/` - a different depth than
`instructions/<name>/SKILL.md` itself, and without the sibling files a
relative link might expect. A markdown link that resolves correctly at
the source (`../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/<name>/` does not reach
the same target from `.claude/skills/<name>/`.
So a `SKILL.md` never writes an outbound reference as a relative markdown
link - it names the target as a repo-root-relative plain path instead
(`` `instructions/session-setup.md` ``, not
`[session-setup.md](../session-setup.md)`). See instructions/CONTRACT.md
§ "A skill's outbound reference is a plain path, not a link" for why that
form survives the copy unchanged.
`docs_verify.check_reference_targets` is the complementary check, over the
flat instructions and contracts that are still allowed to link normally
because nothing ever copies them elsewhere."""
issues: list[str] = []
for source in skill_dirs():
path = source / SKILL_FILE
text = path.read_text(encoding="utf-8")
masked = markdown_code.strip_code_spans(text)
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
for match in docs_verify.MARKDOWN_LINK_RE.finditer(masked_line):
target = match.group(1)
if docs_verify.is_external_or_anchor(target):
continue
issues.append(
f"{rel_path(path)}:{line_number} carries a relative markdown link to "
f"`{target}` - `instructions sync` copies this file to a different depth, "
"so write the target as a plain repo-root-relative path instead"
)
return issues
@app.command("sync")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions sync",
summary="Publish every `instructions/<name>/SKILL.md` into the harness skill directories.",
synopsis=(cli_contract.Variant(usage="instructions sync [--force]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one directory copy per skill per target (`.agents/skills/`, "
"`.claude/skills/`); each copy is idempotent, so a re-run converges even after a "
"partial failure",
budget=cli_contract.Budget.COUNTED,
),
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).",
"Each copy is idempotent, so a re-run converges even after a partial failure.",
),
failures=(
cli_contract.Failure(
cause="No skills found under `instructions/`",
reaction="Fix the named cause and retry",
),
cli_contract.Failure(
cause="A target directory is not a published skill (no `SKILL.md`) and `--force` was "
"not passed",
reaction="Check whether the flagged target holds anything worth keeping, then re-run "
"with `--force` if not",
),
),
examples=(
"tools/wikitool instructions sync",
),
never=(
"Never hand-edit a published copy under `.agents/skills/` or `.claude/skills/` - edit "
"the source and re-run this.",
),
see_also=(
"`instructions/bootstrap.md` - the fresh-clone procedure that runs this",
"`wikitool instructions verify` - checks the copies match",
),
))
def sync(
force: bool = typer.Option(
False,
"--force",
help="Replace a target directory whose contents differ from the source and was not "
"generated by sync. Without this, sync refuses instead of deleting content it did "
"not create.",
),
):
"""Publish every instructions/<name>/SKILL.md into the harness skill directories."""
sources = skill_dirs()
if not sources:
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, 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:
removed.append(rel_path(stale))
if stale.is_dir() and not stale.is_symlink():
shutil.rmtree(stale)
else:
stale.unlink()
published.append(rel_path(target_root))
suffix = f", removed {len(removed)} stale" if removed else ""
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")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions verify",
summary="Check the instruction layer.",
synopsis=(cli_contract.Variant(usage="instructions verify"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"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 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.",
),
failures=(
cli_contract.Failure(
cause="Nothing found under `instructions/` at all, or a malformed instruction or "
"`SKILL.md`",
reaction="Fix the flagged file, then re-run",
),
cli_contract.Failure(
cause="A `SKILL.md` carries a relative markdown link",
reaction="Rewrite it as a repo-root-relative plain path, then re-run",
),
cli_contract.Failure(
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(
cause="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",
reaction="Link it from where it is used, or drop the link to a manual one, then "
"re-run",
),
cli_contract.Failure(
cause="Something under `instructions/dev/` is referenced from outside it and outside "
"a `dist:strip` block",
reaction="Remove the reference or wrap it in a `dist:strip` block, then re-run",
),
),
examples=(
"tools/wikitool instructions verify",
),
never=(
"Never fix drift by hand-editing the published copy.",
),
see_also=(
"`wikitool instructions sync` - publishes the copies",
"`instructions/CONTRACT.md` - the rules this checks",
),
))
def verify():
"""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:
fail(f"Nothing found under {rel_path(config.INSTRUCTIONS_DIR)}.")
issues: list[str] = []
manual: set[str] = set()
# 1. Flat instructions validate against the instruction type-spec.
for path in instructions:
frontmatter, error = _read_frontmatter(path)
if frontmatter is None:
issues.append(f"{path.name}: {error}")
continue
if frontmatter.get("type") != INSTRUCTION_TYPE:
issues.append(f"{path.name}: `type:` should be {INSTRUCTION_TYPE}")
continue
try:
resolver.validate_frontmatter(frontmatter, INSTRUCTION_TYPE)
except ValueError as exc:
issues.append(f"{path.name}: {exc}")
continue
if frontmatter.get("name") != path.stem:
issues.append(
f"{path.name}: frontmatter `name` ({frontmatter.get('name')!r}) "
"does not match the filename"
)
if frontmatter.get("manual"):
manual.add(path.name)
# 2. Skills carry the frontmatter the harness reads. That frontmatter is
# the harness's contract, not this repo's type system's, so it is checked
# directly rather than against a schema.
for source in sources:
frontmatter, error = _read_frontmatter(source / SKILL_FILE)
if frontmatter is None:
issues.append(f"{source.name}: SKILL.md {error}")
continue
if frontmatter.get("name") != source.name:
issues.append(
f"{source.name}: SKILL.md `name` ({frontmatter.get('name')!r}) "
"does not match the folder name"
)
if not frontmatter.get("description"):
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
# 3. No skill carries a relative markdown link - see
# check_skill_reference_paths's own docstring for why the copy breaks it.
issues.extend(check_skill_reference_paths())
# 4. Published copies match their sources. Missing *everywhere* is a clean
# checkout, not a fault - say what to run instead of reporting drift.
expected = len(sources) * len(target_dirs())
missing = 0
drifted: list[str] = []
for target_root in target_dirs():
for source in sources:
difference = drift(source, target_root / source.name)
if difference == "missing":
missing += 1
elif difference:
drifted.append(f"{rel_path(target_root / source.name)}: {difference}")
if target_root.is_dir():
for extra in sorted(target_root.iterdir()):
if extra.name not in {s.name for s in sources}:
drifted.append(f"{rel_path(extra)}: published but has no source")
issues.extend(drifted)
bootstrap_needed = missing and missing == expected and not drifted
if missing and not bootstrap_needed:
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
# 5. An instruction nothing loads is inert. Nothing else would report it -
# unless it is `manual: true`, which inverts the rule over a narrower
# haystack: that instruction must not be linked from AGENTS.md or a
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
# as documentation is fine and expected.
referenced = referenced_names()
forbidden = manual_forbidden_references()
for path in instructions:
if path.name in manual:
if path.name in forbidden:
issues.append(
f"{path.name}: marked `manual` but linked from AGENTS.md, CLAUDE.md, or a "
"skill - that would load it automatically, exactly what `manual` exists to "
"prevent. Remove the link, or drop `manual: true` if it should run routinely."
)
continue
if path.name not in referenced:
issues.append(
f"{path.name}: nothing references it - it deploys to no one. "
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
)
# 6. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
# so nothing outside it may depend on something inside it staying
# around in a distributed instance. See dev_only_forbidden_references's
# docstring for the dist:strip exemption.
for name in sorted(dev_only_forbidden_references()):
issues.append(
f"{name}: lives under instructions/dev/ but is referenced from outside it and "
"outside a dist:strip block - `dist export` removes instructions/dev/ wholesale, so "
"that reference would dangle in a distributed instance. Remove the reference, or "
"wrap it in a <!-- dist:strip-start/end --> block if it belongs only to this dev "
"instance."
)
if issues:
fail("Instruction layer issues:\n - " + "\n - ".join(issues))
if bootstrap_needed:
success(
f"{len(instructions)} instruction(s) and {len(sources)} skill(s) valid. "
"No skills published yet - run `wikitool instructions sync` "
"(see instructions/bootstrap.md)."
)
return
success(
f"{len(instructions)} instruction(s) and {len(sources)} skill(s) valid, "
f"{expected} published copy/copies match their source."
)
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions list",
summary="List the flat instructions with their descriptions.",
synopsis=(cli_contract.Variant(usage="instructions list [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Lists the flat instructions with their descriptions - how the instruction layer is "
"discovered; `search` covers `kb/` only.",
"Never fails: an empty `instructions/` prints \"No instructions found.\" Read-only and "
"safe to retry freely.",
),
failures=(),
examples=(
"tools/wikitool instructions list",
"tools/wikitool instructions list --json",
),
see_also=(
"`wikitool search` - the same question for `kb/`",
),
))
def list_instructions(
json_out: bool = typer.Option(False, "--json", help="Print the listing as JSON"),
):
"""List the flat instructions with their descriptions.
This is how the layer is discovered. `wikitool search` deliberately covers
`kb/` only: a page is found by what it says, an instruction by what it is
for, and that is exactly what `description` carries.
"""
import json as _json
rows = []
for path in instruction_files():
frontmatter, _error = _read_frontmatter(path)
rows.append(
{
"name": (frontmatter or {}).get("name", path.stem),
"path": rel_path(path),
"description": (frontmatter or {}).get("description", ""),
}
)
if json_out:
typer.echo(_json.dumps(rows, indent=2))
return
if not rows:
typer.echo("No instructions found.")
return
for row in rows:
typer.echo(f"{row['name']} ({row['path']})")
typer.echo(f" {row['description']}")
typer.echo("")
typer.echo(f"{len(rows)} instruction(s). Skills are listed by the agent harness itself.")