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
778 lines
35 KiB
Python
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.")
|