DEV Community

Sam Rivera
Sam Rivera

Posted on

Your AGENTS.md Needs an Executable Contract, Not More Advice

An AGENTS.md file can be perfectly clear and completely wrong.

It says tests live in test/, but the directory moved to packages/api/spec/. It tells an agent to run npm test, but the project switched to pnpm. It warns not to edit generated files, while the generator named in the same paragraph disappeared three months ago.

Humans learn to distrust stale documentation. Coding agents are more literal: they may confidently follow the obsolete path.

The fix is not a longer prompt. Put a small, machine-readable contract inside the document and test the claims that can be tested.

Separate guidance from invariants

Most repository instructions contain three kinds of information:

Kind Example How to maintain it
judgment “Prefer the smallest change that preserves the public API” review by humans
fact “Integration tests are in test/integration verify the path exists
procedure “Run npm run check before submitting” execute it in CI

Do not try to turn judgment into a brittle rule. Do turn factual and procedural claims into checks.

Add a fenced block to AGENTS.md:

## Repository contract

```agent-contract
{
  "requiredPaths": ["src", "test", "package.json"],
  "checks": [
    { "name": "tests", "argv": ["npm", "test", "--", "--runInBand"] },
    { "name": "types", "argv": ["npm", "run", "typecheck"] }
  ],
  "reviewAfter": "2026-10-01"
}
```
Enter fullscreen mode Exit fullscreen mode

This block is deliberately boring. JSON has no comments or clever interpolation. Commands are argument arrays, not shell strings. The review date makes untestable prose visible before it becomes archaeology.

A checker small enough to audit

Save this as scripts/check-agent-contract.mjs:

import { access, readFile } from "node:fs/promises";
import { spawn } from "node:child_process";

const documentPath = process.argv[2] ?? "AGENTS.md";
const markdown = await readFile(documentPath, "utf8");
const match = markdown.match(/```
{% endraw %}
agent-contract\s*\n([\s\S]*?)\n
{% raw %}
```/);
if (!match) throw new Error(`${documentPath}: missing agent-contract block`);

const contract = JSON.parse(match[1]);
const allowed = new Set(["npm", "pnpm", "yarn", "bun", "node"]);
let failures = 0;

for (const path of contract.requiredPaths ?? []) {
  try {
    await access(path);
    console.log(`PASS path ${path}`);
  } catch {
    failures++;
    console.error(`FAIL missing path ${path}`);
  }
}

for (const check of contract.checks ?? []) {
  if (!Array.isArray(check.argv) || check.argv.length === 0) {
    failures++;
    console.error(`FAIL ${check.name}: argv must be a non-empty array`);
    continue;
  }

  const [command, ...args] = check.argv;
  if (!allowed.has(command)) {
    failures++;
    console.error(`FAIL ${check.name}: executable ${command} is not allowed`);
    continue;
  }

  const code = await new Promise((resolve, reject) => {
    const child = spawn(command, args, { stdio: "inherit", shell: false });
    child.once("error", reject);
    child.once("exit", resolve);
  });

  if (code === 0) console.log(`PASS check ${check.name}`);
  else {
    failures++;
    console.error(`FAIL check ${check.name}: exit ${code}`);
  }
}

if (contract.reviewAfter) {
  const reviewAt = Date.parse(`${contract.reviewAfter}T00:00:00Z`);
  if (!Number.isFinite(reviewAt)) throw new Error("reviewAfter must be YYYY-MM-DD");
  if (Date.now() > reviewAt) {
    failures++;
    console.error(`FAIL contract review overdue since ${contract.reviewAfter}`);
  }
}

process.exitCode = failures === 0 ? 0 : 1;
Enter fullscreen mode Exit fullscreen mode

Run it from the repository root:

node scripts/check-agent-contract.mjs AGENTS.md
Enter fullscreen mode Exit fullscreen mode

The complete version used for this article also prints passing review dates. I tested it with Node.js 22.22.3: an existing path plus node --version exited 0; a missing path and an unapproved executable exited 1.

Why shell: false matters

Documentation is input. A pull request can change it.

Passing a documentation string to exec() or spawn(..., { shell: true }) quietly turns prose into shell authority. A command such as npm test && curl ... is no longer one test command. Argument arrays and shell: false remove shell operators, substitutions, and redirections from this format.

The executable allowlist is a second boundary. Customize it for the repository, keep it narrow, and require review when it changes. This is not a general-purpose task runner.

There is still an important limitation: an allowed command such as npm test runs repository-controlled code. Execute the checker with the same isolation, credentials, network policy, and approval rules you already apply to pull-request CI. The checker prevents accidental shell interpretation; it does not make untrusted code safe.

Make drift fail where it starts

Add the checker to the same pull-request workflow that validates code:

name: repository-contract
on: pull_request

jobs:
  verify-agent-guidance:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: node scripts/check-agent-contract.mjs AGENTS.md
Enter fullscreen mode Exit fullscreen mode

For a production repository, pin third-party actions to full commit SHAs. Tags keep this example readable but are mutable references.

Now the useful failure happens in the pull request that deletes test/, renames a script, or lets the review date expire. The author can update the instruction and the implementation together.

What belongs outside the block

Keep the contract small. It should not become a second package manager or a homemade policy language.

Good candidates:

  • paths an agent must inspect before editing;
  • the repository's existing format, type, test, and build commands;
  • generated-file boundaries that can be checked by an existing script;
  • a date for reviewing prose that cannot be executed.

Poor candidates:

  • style advice that needs context;
  • secrets or environment-specific URLs;
  • destructive setup and deployment commands;
  • commands copied from third-party issues without repository review.

Start with three facts that have actually drifted before. A ten-line contract that fails usefully is better than a hundred-line schema nobody owns.

Where this applies to coding platforms

The public MonkeyCode repository describes AI task management, project requirements, managed development environments, team collaboration, and private deployment. Repository-level contracts are relevant to that category because instructions need to remain true whichever model or workspace executes the task. This checker is tool-independent; I did not test a MonkeyCode integration or inspect its internal instruction handling.

Disclosure: I contribute to the MonkeyCode project. The product description above comes from its public repository; the executable contract and test are independent.

The broader rule is simple: prose explains intent, while automation protects facts. When both live in the same document, an agent gets guidance that is easier for humans to trust—and harder for repository drift to quietly falsify.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Executable contracts beat advice because agents are very good at sounding compliant. If a rule matters, the system should be able to test or enforce it: file boundaries, forbidden commands, required checks, output formats, or approval gates. Otherwise AGENTS.md becomes a style guide with no teeth.