Skip to content

Commit 98c03ff

Browse files
test: enforce the agent-context contract that review caught it violating (#42)
AGENTS.md told the next agent to keep the file under ~150 lines while being 165 itself. The stated budget was stricter than the fleet standard it cites — handbook#68 says "~150 lines, hard cap ~200" — so the file was the thing that was wrong, not its length. It now states the fleet's budget. Nothing could have caught that, which is the real cause: an agent-context file is followed literally and no test read it. The conformance test now parses the hard cap out of AGENTS.md and asserts the file obeys the number it publishes, so the budget can never again be advice the artifact itself contradicts, and it holds each skill to the Agent Skills frontmatter contract. Each assertion was proved red by reintroducing the defect it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e2f1387 commit 98c03ff

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

‎AGENTS.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ red is the correct outcome and the skip would buy a false green.
9393
this repo does not pass `Only(...)` to make absence fatal — so
9494
`TestPythonFastAPILifecycle_Matrix/nix` is the one skip in a green CI run.
9595
Changing `nixflake.go` or `nix/flake.nix` is unverified by CI: exercise it
96-
locally with `nix` installed, and say in the PR that you did.
96+
locally with `nix` installed, and say in the PR that you did. Closing that gap
97+
is issue #49.
9798

9899
CI installs `uv` and pins `syft` v1.48.0 in `setup-run` so the other halves do
99100
run. The full skip matrix is in the `lifecycle-test-triage` skill.
@@ -158,8 +159,10 @@ carried here:
158159
or `ci / build` goes red on a change that passed every other local check.
159160
- `agent.codefly.yaml` carries the agent version; releases are tag-driven
160161
(`.github/workflows/releaser.yml`), never hand-built.
161-
- Keep this file under ~150 lines (hard cap 200). Push depth into a nested
162-
`AGENTS.md` beside what it describes, or into `.claude/skills/`.
162+
- Keep this file near ~150 lines, hard cap 200. Push depth into a nested
163+
`AGENTS.md` beside what it describes, or into `.claude/skills/`. The cap and
164+
each skill's frontmatter are asserted in `conformance_test.go`, so `go test
165+
./...` enforces them with no workflow change.
163166
- There is no `CLAUDE.md`. If one is added, it is a one-line `@AGENTS.md`
164167
pointer — one canonical source, never a second copy to drift.
165168
- Treat this file as code: the PR that changes a process updates it.

‎conformance_test.go‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,98 @@ func TestReleaseCanCompileTheCgoTheBinaryNeeds(t *testing.T) {
294294
"so CGO_ENABLED=1 has no macOS or cross-linux compiler to use")
295295
}
296296
}
297+
298+
// agentContextCap extracts the line budget AGENTS.md states for itself. The
299+
// budget is parsed rather than hardcoded so the assertion is that the file
300+
// obeys its own stated rule: a file that tells the next agent to stay under a
301+
// number it has itself exceeded is an instruction that reads as false.
302+
var agentContextCap = regexp.MustCompile(`hard cap (\d+)`)
303+
304+
// skillFrontmatter is the Agent Skills contract: name and description are the
305+
// only required fields, and the name is what the directory must be called.
306+
type skillFrontmatter struct {
307+
Name string `yaml:"name"`
308+
Description string `yaml:"description"`
309+
}
310+
311+
func TestAgentContextFilesHonourTheirOwnContract(t *testing.T) {
312+
raw, err := os.ReadFile("AGENTS.md")
313+
if err != nil {
314+
t.Fatalf("read AGENTS.md: %v", err)
315+
}
316+
lines := strings.Count(string(raw), "\n")
317+
318+
match := agentContextCap.FindSubmatch(raw)
319+
if match == nil {
320+
t.Fatal("AGENTS.md does not state a hard cap; the length budget is unenforceable")
321+
}
322+
stated, err := strconv.Atoi(string(match[1]))
323+
if err != nil {
324+
t.Fatalf("parse hard cap %q: %v", match[1], err)
325+
}
326+
if lines > stated {
327+
t.Errorf("AGENTS.md is %d lines but states a hard cap of %d; push depth into a skill", lines, stated)
328+
}
329+
330+
// A CLAUDE.md is optional, but when present it must stay a pointer rather
331+
// than a second copy that drifts from this file.
332+
if pointer, err := os.ReadFile("CLAUDE.md"); err == nil {
333+
if strings.TrimSpace(string(pointer)) != "@AGENTS.md" {
334+
t.Errorf("CLAUDE.md must be the single line @AGENTS.md, got %q", string(pointer))
335+
}
336+
}
337+
}
338+
339+
func TestSkillsDeclareTheRequiredFrontmatter(t *testing.T) {
340+
entries, err := os.ReadDir(filepath.Join(".claude", "skills"))
341+
if err != nil {
342+
if os.IsNotExist(err) {
343+
t.Skip("no skills directory")
344+
}
345+
t.Fatalf("read skills dir: %v", err)
346+
}
347+
348+
valid := regexp.MustCompile(`^[a-z0-9-]{1,64}$`)
349+
for _, entry := range entries {
350+
if !entry.IsDir() {
351+
continue
352+
}
353+
path := filepath.Join(".claude", "skills", entry.Name(), "SKILL.md")
354+
raw, err := os.ReadFile(path)
355+
if err != nil {
356+
t.Errorf("%s: %v", path, err)
357+
continue
358+
}
359+
// Frontmatter is the block between the leading --- fence and the next.
360+
body := string(raw)
361+
if !strings.HasPrefix(body, "---\n") {
362+
t.Errorf("%s: no YAML frontmatter", path)
363+
continue
364+
}
365+
end := strings.Index(body[4:], "\n---\n")
366+
if end < 0 {
367+
t.Errorf("%s: frontmatter is not terminated", path)
368+
continue
369+
}
370+
var front skillFrontmatter
371+
if err := yaml.Unmarshal([]byte(body[4:4+end]), &front); err != nil {
372+
t.Errorf("%s: parse frontmatter: %v", path, err)
373+
continue
374+
}
375+
if front.Name != entry.Name() {
376+
t.Errorf("%s: frontmatter name %q does not match directory %q", path, front.Name, entry.Name())
377+
}
378+
if !valid.MatchString(front.Name) {
379+
t.Errorf("%s: name %q must be lowercase letters, digits and hyphens, at most 64 chars", path, front.Name)
380+
}
381+
// The description is all an agent sees before loading the skill, so an
382+
// empty one makes the skill undiscoverable and an over-long one is
383+
// rejected by the standard.
384+
if front.Description == "" {
385+
t.Errorf("%s: description is required", path)
386+
}
387+
if len(front.Description) > 1024 {
388+
t.Errorf("%s: description is %d chars, over the 1024 limit", path, len(front.Description))
389+
}
390+
}
391+
}

0 commit comments

Comments
 (0)