Teach it a house rule¶
The test-reader from the last page knows what a test is. It does not know what your team means by a bad one, and neither does any model: “asserts on a value it set up itself” is a house rule, one of a dozen, and the list is the same for every agent that will ever read a test here. Pasting it into each system prompt is how three copies drift apart.
A skill is that list as a file, named by the agents that may use it, and opened by the model when it needs it. This page gives the test-reader one and watches whether it reads it.
Write it beside the agent¶
mkdir -p .pi/agents/test-reader/skills/assertion-smells
---
name: assertion-smells
description: A checklist of assertions that check less than their test's name promises. Use when reading a test file.
---
# Assertion smells
Go through this list for every test. Name the smell, not the number.
1. **Existence only.** `assert.ok(result)` on a value that cannot be falsy.
The test proves the function returned.
2. **Round trip.** Asserts equality with a value the test itself put in a
fake. The test proves the fake works.
3. **No negative case.** A test named "rejects", "refuses" or "fails" that
never asserts on the error, only that something happened.
4. **Cap without convergence.** A loop or retry test asserting the count of
iterations and not what the last one produced.
5. **Order not asserted.** A test named "in order" that asserts on membership.
6. **Elapsed not asserted.** A timeout test that asserts the error message
and never the time it took: a correct label on a hanging turn is not a guard.
That is pi’s own skill format, SKILL.md with a name and a description,
in .pi/agents/test-reader/skills/. The directory is chosen for you: the
definition minus its .md, then skills/. agents/scout.md keeps its skills
in agents/scout/skills/, so a definition and what it cannot work without
travel together, and a clone of the repository resolves the same names.
Then name it in the agent:
---
name: test-reader
description: Reads a test file and reports what each test really asserts, against what its name promises
tools: read, grep, find, ls
skills: assertion-smells
lifetime: task
---
skills: is an allowlist exactly like tools:. An agent that names nothing
gets nothing, whatever is installed on the machine, so what an agent can reach
stays readable in its own file.
Watch whether it opens it¶
> use subagent with scope "project" and agent "test-reader" to read
test/fan-out.test.ts
subagent single test-reader
read test/fan-out.test.ts
✓ test-reader#1 read test/fan-out.test.ts
read test/fan-out.test.ts
1 turn 60.5s ↑5.3k ↓2.4k
ctrl+o to expand
It never opened the skill. Nothing was loaded eagerly: pi put the skill’s
name, its description and its path in the system prompt, and left the model to
read the file when the task matched. pi’s own documentation says models do
not always do this. On the small open-weight model here it went straight to
the tests, never read the list you wrote, one line away, and ended “No tests
promise more than they check.”
So the definition has to send it there. Add a sentence to the prompt body:
Before you read any test file, open the `assertion-smells` skill and keep its
list beside you: every smell you name comes from it.
subagent single test-reader
read test/fan-out.test.ts
✓ test-reader#1 read test/fan-out.test.ts
read /…/combo/.pi/agents/test-reader/skills/assertion-smells/SKILL.md
read test/fan-out.test.ts
1 turn 12.8s ↑7.5k ↓2.2k
ctrl+o to expand
First call, the skill. The report that came back was a line per test, twenty of them, and not one named a smell:
returns one result per task, in the order of tasks: asserts that result outputs match task order regardless of completion speed.
accepts one agent per task: asserts that each result is associated with the corresponding agent.
…
No tests promise more than they check.
accepts one agent per task is the second smell on the list, a round trip:
the test hands the fan-out scout and coder and asserts it got scout and
coder back. The reader had the list in front of it and did not see it. A
skill is a page the model has read. It does not constrain what the model
concludes. The definition can insist, and the tool row can show you whether it
complied, and that is as far as the guarantee goes.
Where a name resolves¶
Three places, nearest first, and the first one that has the name wins:
.pi/agents/test-reader/skills/- beside the definition. Reproducible..pi/skills/in the repository, found by walking up from the working directory. Reproducible, and shared by every agent in the repository.~/.pi/agent/skills/- yours. A convenience that depends on the machine.
That third one is the deliberate hole in “a subagent inherits nothing”: the name still has to be written in the definition, but where it resolves can depend on whose machine the run is on. A skill the agent cannot work without belongs in the first place, not the third.
What fails, and when¶
At spawn, and by name. Misspell it, skills: assertion-smell, and the call
does not start:
subagent single test-reader
read test/fan-out.test.ts
Agent "test-reader" declares unknown skill(s) assertion-smell. "assertion-smell": did you mean "assertion-smells"?
Skills found: assertion-smells. Looked in:
/…/combo/.pi/agents/test-reader/skills, /home/you/.pi/agent/skills.
The refusal says what it found as well as where it looked: the skills in
those directories, and the nearest name among them. Two directories are listed
and not three, because this repository has no .pi/skills/. A skill goes by
the name: in its SKILL.md, not by its directory, so renaming a directory
never fixes a refusal. If a directory named assertion-smell held a
SKILL.md saying name: assertion-smells, the message would say exactly that.
The session read the hint. It ran find for the agent’s files, read
.pi/agents/test-reader.md, and called edit to change
skills: assertion-smell to skills: assertion-smells. On the second call the
reader opened the skill first, as its prompt asks, and reported twenty tests
without naming a single smell, accepts one agent per task included. The edit
was the right one, and it was still a session rewriting an agent’s definition
to get past a refusal: it holds edit and bash, and it did not ask before
editing. Under version control that shows as a one-word diff; read it before
you trust the next report.
Two more refusals, both for a skill that would resolve and never be seen: an
agent whose tools: lacks read cannot open the file, and pi drops the whole
skills section from its prompt, so combo refuses at spawn rather than let the
agent run without it. And a skill whose frontmatter sets
disable-model-invocation is kept out of the system prompt by pi, so the agent
would never know it existed. A flow naming the agent is refused the same way,
before its first turn, with unknown-skill, skills-without-read or
skill-hidden. Finding out through prose that quietly lacks a step costs more
than failing at once.
You now have an agent that cannot write, reads by your rules, and tells you when its rules are missing. It is half of a loop. The other half writes, and has to be argued with.