Contributing
August 26, 2026 · View on GitHub
Two things are easy to contribute and neither needs much Go.
Adding a creature
Creatures live in one slice in internal/identity/identity.go. Adding one is a
single line:
{"newt", "~[", "]~", Uncommon},
That is a name, the two fragments that bracket its face, and its rarity band.
~[ and ]~ around the face •ᴗ• render as ~[•ᴗ•]~.
Rules, all enforced by go test ./internal/identity/:
- The name must be unique.
- Neither fragment may be longer than three characters, or rows stop lining up.
- The band must be one of
Common,Uncommon,Rare,Legendary,Mythic. - The creature must be original. No third-party characters, however loosely.
Rarity odds come from a weight table, not from how many creatures sit in a band, so adding ten legendaries does not make legendaries common. It only makes each one rarer to draw.
Please check your creature reads at a glance in a terminal, not just in a diff.
Faces range from •ᴗ• down to x_x, so try it at both ends.
The line is small and the blast radius is not
Read this before opening the PR, because the diff looks free and is not.
A creature is picked with pool[Hash(key) % len(pool)], so growing a band
changes which creature every existing key in that band draws. Adding one
Common is enough. Measured, from adding a single new Common:
For("main") mouse -> toad
For("master") vole -> toad
For("trunk") wren -> moth
For("a") crab -> carp
Three consequences, in rising order of how much they matter:
- Two tests fail, in two packages: the golden pin in
internal/identityand a party test ininternal/render. That is the pin doing its job, not a mistake you made. Repin it in the same PR. - Everyone's pet changes when they upgrade. The mouse someone has had in
mainfor a month becomes a toad. den.jsonkeeps the old species names, and it is the only precious state here. Socollection.Has()stops matching what is on screen: the den quietly re-earns creatures it already holds, and the entries it held before become unreachable, because no key summons them any more.
None of that is a reason never to add creatures. It is the reason roster growth is batched into a release and called out in the notes, rather than merged one cute animal at a time. If you have a creature you like, open it anyway and say so - it may just wait for company.
Whether that stays the policy is an open question, and the answer partly depends on how many creatures are waiting. Post yours there too.
Adding a signal
A signal is any executable that prints key=value lines on stdout. It receives
the worktree path as its first argument. Drop it in ~/.config/pets/signals/.
#!/bin/sh
# ~/.config/pets/signals/cargo.sh
echo "clippy=$(cargo clippy --message-format=short 2>&1 | grep -c '^warning')"
By default that only adds a row to pets card. Give it a bound and a cost under
[signals.external.penalties.<name>] and it costs hearts like the built-in signals,
and the card shows it in amber. over for things that are bad when they rise,
under for things that are bad when they fall.
Signals are user configuration rather than part of the tool, so there is nothing to submit unless you want one documented in the README as an example.
Hook fixtures
cmd/pets/testdata/ holds payloads captured from a real session, not written by
hand. That distinction is the point: a hand-written fixture encodes what its
author believed the harness sends, so a test built on one agrees with the
author's mistake. This project shipped that exact bug once, and every test passed
while the code read escaped JSON source instead of text.
To refresh one, point the hook or status line at a script that tees stdin before
calling pets, then sanitise it before committing. A real payload carries
your username in several absolute paths, your repository owner, the session name
and cost.total_cost_usd. Replace them with the example/demo and
/home/user/... placeholders already used in the existing fixtures, and keep
the worktree path exactly as it is: the tests rewrite that one field and nothing
else, so the shape stays as the harness sent it.
Assert what the hook wrote, not what it returned. Its product is a cache entry with an expiry that a later render reads, so a function can decode perfectly and still record the wrong verdict.
Working on the code
go test ./...
go build ./cmd/pets
The binary reads and writes three locations, all redirectable, so you can exercise a build without touching your own collection or agent config:
export HOME=/tmp/pets-sandbox
export XDG_CONFIG_HOME=/tmp/pets-sandbox/config
export XDG_STATE_HOME=/tmp/pets-sandbox/state
HOME is what pets install writes into, and the two XDG paths hold config and
the collection. Nothing else on disk is touched.
Two things worth knowing before changing them:
Identity must stay stable. A key is supposed to summon the same creature on
any machine, for as long as the roster holds still. internal/identity has a
golden test pinning specific keys to specific creatures, and it is there to make
an accidental reshuffle loud. If you change identity deliberately, repin it and
say so. Adding a creature is one of the ways to change it: see the blast radius
above.
The render path never runs git and never touches the network. pets render
executes once a second in every open session, so it reads two small files and
nothing else. The expensive work happens in pets probe, backgrounded and
throttled. Update checks run only from card, party and den, which a person
types.
Conduct
By taking part you agree to the Code of Conduct. It is the Contributor Covenant, unmodified.