Tools Return Data, Agents Make Decisions

I watched an agent run openpkg diff old.json new.json. It got back a complete, correct wall of changes and started counting.

It wanted to know one thing, "did anything break", and I'd handed it a dataset. The verbs were right after the December cleanup. The outputs were answering a question nobody asked.

Three questions

A diff gets consulted for three different reasons, and each consumer already speaks a shape:

Who asksThe questionThe shape they want
CIdid anything breakan exit code
a release scriptwhat version is thisa string, major / minor / patch
a changelogwhat changed, for humansmarkdown

Same computation, three answers. This week's commit split the surface and kept one engine underneath.

Fig. 1Three questions, three shapes of answer
one engine: diffSpec + categorizeBreakingChanges + recommendSemverBump

breaking

did anything break?

{
"breaking": [
{ "name": "fetchUsers",
"reason": "signature changed" },
{ "name": "connect",
"reason": "removed" }
],
"count": 2
}

exit code 1, the gate itself

semver

what bump does this need?

{
"bump": "major",
"reason": "2 breaking
changes detected"
}

always exit 0, a recommendation

changelog

what do I tell humans?

## Breaking Changes
- **Removed** `connect`
- **fetchUsers**: signature
changed
## Added
- `ping`

markdown, release-note material

Real output from the same diff engine. Each command answers one question in the shape its consumer already speaks.

breaking doesn't print a report and wish you luck; the exit code is the answer, so a pipeline consumes it with zero parsing. semver always exits 0, and the source has a comment insisting on it ("this is a recommendation only"), because recommending is data and enforcing would be a decision.

filter

The command that fell out of the refactor unprompted: take a spec and criteria, get a smaller spec back.

Terminal
openpkg filter spec.json --kind function --search fetch
{ "matched": 1, "total": 5, "exports": ["fetchUsers"] }

The flags read like questions an agent asks: --deprecated, --missing-description, --tag, --search-members, --summary for counts only. It never changes the spec, and since every other command takes a spec as input, filter composes in front of all of them.

Who decides

If the tool decides, it's a feature, and features don't compose. If the tool returns data and the caller decides, it's a primitive.

CommandDecidesReturns
breakingnothing; the categorizer in the spec package defines "breaking", in versioned codepass / fail
semvernothing; "recommendation only"a bump
diagnosticsnothing; missing descriptions, deprecations without reasons, undocumented paramsfacts, no verdict

Your CI config gets to have the opinion. Rule I keep: a tool's output should still be useful to a program I haven't written yet.