Writing a skill, and the gate it has to pass
Everything here is a rule that exists because something real broke. The rule lists live in the repo, where an agent reads them and where they get updated; this page carries the arguments behind them — which is the part that does not fit in a checker.
Anatomy of a skill
A skill is a directory under skills/ containing a SKILL.md and,
optionally, scripts/ for its deterministic engine, references/ for
material the agent loads on demand, and evals/. Three of the ten skills are
SKILL.md alone. Nothing else is required, and nothing else is read.
One frontmatter constraint shapes the whole repository. The Agent Skills specification
requires name to equal the parent directory name — so the freya-
prefix has to live in the checkout rather than be applied at install time. Installing is
symlinking a directory straight into the agent's skills folder, and every agent shares the
same file on disk; a single SKILL.md saying name: code-graph inside
a directory called freya-code-graph cannot be rewritten per-install, because
there is only one of it. That is why the directories were renamed instead of the installer
doing the work.
Then two things that went wrong here, both of them the same shape: a guard built out of a remembered fact instead of a read one.
The "non-standard" field that was in the standard
The design described compatibility: frontmatter as non-standard and had it
removed. The specification lists it as a standard optional field, alongside
license, metadata and allowed-tools. Worse, the error
had already propagated into the conformance checker, whose allow-list was
{name, description} — so it was rejecting four legitimate fields. Removing
those two particular lines was still right, because they enumerated Claude tool names. The
stated reason was wrong, and the rule built on it was wrong. A guard built from a
remembered fact inherits the memory's error and then enforces it on everyone.
Nine skills, not ten
Asked to list its installed skills, GitHub Copilot returned nine. The missing one was
freya-codebase-security-scan — the security scanner, absent on the very agent
the port existed to reach. Installed, correctly symlinked, silently dropped. Its
description had grown to 1251 characters against the spec's 1024
limit. Copilot enforces the limit and says nothing; Claude Code ignores it and
loads the skill happily, which is precisely why a long run of work done mostly against
Claude never saw it. Nothing raised an error — not the CLI, not freya doctor,
not the gate, whose rule R5 had only ever checked which frontmatter
keys were present and never how long their values were.
R10 now measures every length the specification states — description 1024,
compatibility 500, name 64 — and R11–R13 close the same class from
the other side: a required description that is present and non-empty, a
name inside the spec's grammar, and Claude-only locations no rule had ever
looked at. All four came out of reading the specification rather than recalling it.
The full rule text a contributor has to satisfy — every convention, what the gate actually enforces versus what it merely hopes for, and the release mechanics for both consumer paths — is in the repo, and is the copy that gets updated.
Commands, not paths
No SKILL.md names a filesystem path. Scripts are invoked as
freya <command> and nothing else. The line this replaced carried two
independent failures at once:
# a Claude-only variable, and an interpreter that may not exist
python "${CLAUDE_PLUGIN_ROOT}/skills/spec-manager/scripts/drift.py" gaps --project .
# and a host-specific way to name a sibling skill
/freya-devkit:code-graph
${CLAUDE_PLUGIN_ROOT} is set by Claude Code and by
nothing else, so anywhere else it expands to nothing and the path is garbage. And
python is not python3 on a great many machines — on some it is
Python 2, on others it does not exist at all.
freya drift gaps --project .
# a sibling skill is named, not pathed
freya-code-graph
One launcher owns both "where am I" and "which Python". Note the
one-character distinction: freya code-graph (space) is a CLI command,
freya-code-graph (hyphen) is a skill name. They are never interchangeable.
Behind that are three deliberately boring pieces. bin/freya is an executable
shim, almost empty on purpose — it exists to put the real module on the import path.
bin/freya_cli.py holds all the logic, so it is importable and unit-testable
without spawning anything. bin/commands.json is a checked-in manifest mapping a
friendly name to a script path. Those registered commands, plus six built-ins that
main dispatches before it ever consults the manifest — help,
doctor, init, install, uninstall and
update — are the entire public surface.
Three mechanisms in there are worth stating, and none of them is worth transcribing.
The shim resolves the suite from its own __file__ via
os.path.realpath, not os.path.abspath. That looks
like a stylistic nit and is not. Every installation is a symlink, and abspath
resolves to the symlink's own directory while realpath follows it to the real
one. Ordinarily CPython papers over the difference by inserting a resolved
sys.path[0]; under -P, PYTHONSAFEPATH or isolated mode
it does not — and then import freya_cli fails from a directory that visibly
contains freya_cli.py. The line was written before symlink installs existed, so
the bug it avoids could not have surfaced for a long time after the mistake was made.
Dispatch runs the target with sys.executable — the interpreter already
running, which is by definition present and correct. Every one of the 80 original
invocations called bare python, so routing them all through one launcher fixed
a breakage nobody had reported yet.
One test fails if a manifest entry points at a script that does not exist. A second fails
if a script with a __main__ block is not registered. So a new CLI cannot
be added and silently left unreachable, and a deleted script cannot leave a dangling command
behind. If you add a script, add its manifest entry — the suite will tell you if you forget.
The command list itself is the manifest — read it there rather than from a page that goes stale the next time a command is added. The architecture file explains the resolution order, and the ADR records what this beat.
The gate: fourteen rules, no exemptions
bin/check_skill_conformance.py runs over skills/**/*.md and
skills/**/*.py and exits non-zero on a single host-specific construct. Fourteen
rules, each one added red, against a real violation — none speculative, none
decorative. It runs on every push, separately from the test suite, because a shipped
SKILL.md can violate most of the fourteen with the whole suite green.
One of the fourteen is not about portability. R14 arrived in
the post-0.3.0 security pass and rides along on the same machinery for the same reason — it is a
whole-tree property of prose that no unit test can express. It requires a skill that sends
a worker at secret-bearing material to state the redaction rule, and to restate it
inside the template slot the writer actually fills by copying source out of the scanned
project. Stating it three sections away is what the rule exists to catch: that was the
shape of SEC-009, where the report template's evidence block took a bare
{code snippet} and, for a Secrets finding, the vulnerable code is the
credential.
R1 and R4 once carried exemptions for a Claude-only audit
engine. Deleting that engine removed both exemptions in the same commit, and the
checker has had none since — so one leftover host-specific reference anywhere fails the
build. That is what makes "the port is finished" a claim you can check in one command rather
than take on trust.
Red first, then green
The strongest thing on this page is the order the work happened in, and it appears in no markdown file. The checker was written before a single rewrite, and run against the tree on day one it failed loudly. Every rewrite task then existed to move it toward zero.
A find-and-replace pass has no natural end. You stop when you stop seeing hits, which is
exactly when the remaining hits are the ones your search missed. A checker inverts that: the
job is done when a program says so, and every miss it catches becomes a permanent rule rather
than a one-off correction. The rule set grew from seven to thirteen while the work ran (the
fourteenth came later, from a different pass), and
the checker was widened mid-flight to scan skills/**/*.py as well as markdown,
after a Python file was caught writing a Claude-only command into its generated output — a
regression class markdown-only scanning could never see.
What the gate cannot see
A gate that names its own blind spots is the point of having one. Three are known and written down rather than hidden:
R9is file-scoped. If a file already carries the portability clause and you add a second, unrelated fan-out lower down in the same file, the rule will not catch it. That trade-off is recorded in CONTRIBUTING.md with the instruction to check by hand.R4was too narrow, and falsified a completeness proof. Retiring the audit engine claimed thatR1andR4having no exemptions left meant a single leftover reference would fail the gate.R4's pattern required the literal wordtool— so three "Workflow-powered" references shipped straight past it, two of them in the skill layer, describing an engine that had been deleted. The proof was sound in structure; the detector was too narrow. The definition-of-done grep made the identical mistake, which is how it survived twice.- The space-versus-hyphen hazard is structurally invisible to it. See the gotchas — that one is caught by review or not at all.
The rules themselves are not reproduced here on purpose: the checker is its own source of
truth, it will grow, and a copy of R1–R14 on a web page is a copy that will be wrong. Read
the RULES dict at the top of the file — it is written to be read.
Fan-out in a skill you write
If your skill presents N independent units of work, it has to schedule them portably. Two shapes are in the tree, and which one you need depends on who owns the loop.
Prose fan-out
Separate the N units of work from the scheduling, and place the canonical
portability clause after the task list, so the structure is read first.
The clause has to carry all three parts: run them in parallel if your agent supports
subagents, a sequential fallback for when it does not, and the token-cost note —
a sequential run accumulates every task's reading context into one window and may not fit.
R9 fails the build wherever fan-out language appears without it. Copy the
reference block rather than paraphrasing it; the gate checks for a sentinel phrase and a
fallback, which is a floor, not the convention.
Driver-owned fan-out
Our own code owns the loop and calls whichever agent CLI is installed as a headless
worker, through one injected ask callable. The engine never
imports an agent, never spawns a process, and never knows its host. In production
ask shells out; in tests it is three lines returning canned JSON, which is why
the entire driver suite runs offline and free. The per-agent surface is one small module —
audit_adapter.py, 224 lines — holding argv, envelope parsing, cost telemetry
and CLI detection. Adding a third host means an argv builder and a stdout parser.
(It was 116 until the post-0.3.0 security pass; the growth is the binary resolver and its
reasoning, not a second host.)
Use the driver form when the workers only need to read. Keep the prose
form when the workers write files — the driver's whole guarantee rests on
workers that cannot write, so a writing worker cannot be put behind it. That is why
freya-docs-manager keeps the prose block even though it fans out to twelve
workers against the security scan's six. Scale was never the criterion.
GitHub's "deny beats allow" applies to the write tool, not to writes performed
through the shell: --allow-all-tools --deny-tool=write let a worker
create a file with a shell redirect. Only an explicit allowlist that excludes the shell held.
So every argv here is an allowlist rather than a blanket grant, and build_argv
refuses to emit a blanket permission flag even if one is smuggled in through the
prompt — both adapters return through that guard, and the one place a subprocess is
spawned sits behind it, so there is no bypass path.
The canonical clause, which file holds the reference copy, and exactly what
R9 does and does not enforce are all in the contributing guide; the reasoning
for inverting the dependency is in its ADR.
Testing, and what a green suite does not prove
Four disciplines. The counts are deliberately not on this page — they are attached to every CI run, where they are current.
Nothing paid, nothing networked
The audit engine takes an injected ask; the updater takes an injected
run. Not one test invokes a real agent or reaches a remote, so the whole suite is
free and works offline. That constraint had to be re-enforced after review found the suite had
escaped onto the developer's real home directory — a safety wrapper had been wired in front of
main, quietly turning four existing tests into ones that wrote the real
~/.freya and called the network. Real dependencies are still preferred where one
can be produced honestly: the updater's tests drive real git in temporary
directories, an origin repo plus a clone.
The CI matrix, and its uncovered diagonal
Two jobs — the suite plus three static gates, and a real end-to-end install — across
ubuntu-latest and windows-latest, on Python 3.9 and 3.13. Install is
a separate job on purpose, so a failing test cannot hide the install's answer behind an early
exit.
One of the three gates reads something the suite cannot; the other two overlap it on
purpose. check_skill_conformance.py scans the shipped skill layer for
host-specific constructs, and check_invariants.py reads the AST for two
whole-tree properties: that every import is stdlib or a sibling, and that no
subprocess call takes a bare-name argv[0] unless that site is on
an explicit allowlist. Both of those also run from inside pytest — each has a
ShippedTreeTest driving the same scan over the same tree — so they are kept
for the report they print, naming the file, line and rule, not because the suite would
miss the violation. check_doc_citations.py is the one that genuinely stands
alone: its repo-level test pins only the rule that no document cites an untracked path,
leaving the other two rules to the script. It resolves every path:line
citation in the prose — a four-figure count the gate prints on each run, and one that
moves with every commit, which is why the number belongs in that output and not on this
page — because a citation that rots is a document quietly lying about the code.
It catches three things and only three: a cited file that is not in the tracked tree, a
line past the end of that file, and a citation landing on a blank line. A citation
that has drifted onto the wrong non-blank line stays green, so a passing
run is not evidence that a number is right — which is why the post-0.3.0 pass re-derived
every moved citation by matching the cited text rather than by trusting the gate.
Both are properties of the whole tree, and a per-file test cannot see them —
which is why the tests that do cover them are whole-tree scans calling the same code. The
stdlib rule fails invisibly on the machine that breaks it — you write
import yaml, it works for you because you happen to have it, and it breaks for
everyone else. The argv rule is the shape of two high-severity findings this toolkit's own
security scan raised against it: a worker invoked as bare claude with the scanned
repository as its working directory is, on Windows, an invitation for that repository to
supply its own claude.exe. Both of those two findings are fixed — the agent
CLIs and graphify are resolved to an absolute path or refused — which is a
narrower statement than "every external program", and the callout below says why.
It is not "no bare names remain". Eight sites still spawn a bare
git, across seven files, and they are carried in an explicit
allowlist that the gate reads. That list is a debt marker, not an approval: the gate goes
red the moment a ninth appears or one of them names anything other than git,
and a test pins the census at exactly eight. There is a second, subtler limit — a site that
gets fixed stops being visible to the rule at all, because
subprocess.run([exec_path.resolve(...).path, …]) is a call expression the AST
check cannot evaluate. Fixed sites therefore leave the census rather than passing
it, which is why a fixed site's allowlist entry is deleted in the same commit instead of
being left behind as cover. The same blindness cuts the other way, and it is worth knowing
before you read the census as a total: an argv built into a variable first is invisible
too, so run_behaviors.py:228's ["pnpm", "vitest", …], spawned at
:459 with the scanned project as its working directory, is a bare name the
gate never counted — neither resolved nor allowlisted. Running a project's own test command
means executing that project's code regardless, so it is left as it is; the point is that
"eight" is eight the rule can see.
The install job runs symlink mode on Linux and --copy on
Windows, because that mirrors how each platform is actually used — which leaves the
opposite diagonal, Linux --copy and Windows with Developer Mode, unexercised on
both. Touch either and test it by hand. And no agent CLI is installed on the runner: CI proves
the toolkit installs and its own tests pass on Windows, never that a live scan runs there.
Mutation-tested guards
Safety-critical guards — the audit engine's vote arithmetic, the read-only allowlist, the
installer's occupied classification — are broken on purpose to confirm a named
test notices. The vote arithmetic's tests all passed before that; three of them turned out to
be hollow, and each names a distinct way a test can be green without proving anything.
| Mutation | Why the test missed it |
|---|---|
upheld * 2 > total → >= |
Only a 1-of-2 split distinguishes the two operators. Every test used three skeptics, where they agree. |
K_EMPTY = 2 → 3 |
The test asserted against audit_engine.K_EMPTY itself, so it
adapted to the mutation and stayed green. |
delete the dry = 0 reset |
The test counted findings, and both paths yield one. Only the round count differs. |
Mutation runs that land inside a single filesystem timestamp tick make Python reuse cached
bytecode and report the previous mutation's result. Two mutations looked killed when
they had never been loaded. Clear __pycache__ between runs, or use
python3 -B.
Review the plan, before the code
Three plan-mandated choices were sent to an adversarial reviewer before
implementation, and two of them were wrong. The most useful one was not a style disagreement:
the plan left a failed-fetch branch uncovered and forbade mocking it, on the
grounds that an offline remote cannot be simulated honestly. But delete that guard and the
flow reaches merge-base against the stale local ref, which succeeds — so
an offline machine prints "already up to date" and exits 0 over a store that
is not up to date. That is not a coverage gap; it is a wrong answer, and the one that command
must never give.
"This error path can't be tested honestly" is a claim about the test. It says nothing at all about whether the branch's absence is visible to the user. Answer the second question separately, every time.
Current test counts, the exact matrix, and what each job asserts are in the workflow file, and every run's results are attached to the commit. The evidence discipline behind all of the above — real dependencies, mutation-tested guards, committed evidence, dated corrections — is one ADR.
The gotchas that cost us
Five things that have actually bitten, in the order you are likely to meet them.
One character, two meanings
freya code-graph with a space is a CLI invocation — the launcher runs a
script. freya-code-graph with a hyphen is a skill name — the agent loads
instructions. They are never interchangeable. Across 257 rewrites the confusion fired
exactly once, a skill name carrying CLI flags, and it was caught by review, not by
the checker, which structurally cannot see it.
Flag names are not uniform
Most scripts take --project. Some take --dir:
graph_ops.py's --dir is a project root, while
verify_links.py's is a specs directory. Same flag, different noun. It
is a real inconsistency rather than a documentation error, and it is worth knowing before
you write a wrapper around any of them.
A diagnostic must survive the install it diagnoses
freya doctor's first version had the same defect twice, in opposite
directions. When the manifest failed to load it still printed "all scripts present"
— reporting absence of evidence as evidence of absence. And given structurally valid but
semantically wrong JSON, it dumped a traceback: the command whose entire job is diagnosing a
broken manifest could not survive one.
A preview that lies is worse than no preview
The installer's "replaced" label was unreachable in --copy and
--dry-run: copy always said "copied" even when it had just unlinked a foreign
symlink, and dry-run picked its wording before it knew a replacement was involved. Separately,
uninstall ignored --dry-run outright and deleted all ten symlinks —
a flag whose help text reads "print the plan, change nothing". Every unit test called the
uninstall function directly; nothing tested the flag wiring.
Keep the shell thin
install.sh, install.ps1 and freya install are all
thin bootstraps over one Python module, and the payoff was measured on the first CI run that
had ever executed on Windows: both install jobs went green while the two Windows test
jobs failed 29 times. Nothing had broken — the problems had been sitting there the
whole time. The part with no logic in it worked.
Two of the integration conventions are the ones a skill author trips over: scripts are
invoked as freya <command> and no SKILL.md names a path, and any
flow asking for parallel workers states what to do when the agent will not delegate. The rest
— tracking files, artifact locations, dated-report idempotency, cross-reference naming — are
in the conventions file.