Development
Model Peer is one Bash script. There is no build step and no runtime dependencies beyond the vendor CLIs.
make test # smoke tests against stub CLIs; runs check-sync first
make lint # bash -n over every script, plus shellcheck when installed
make sync # regenerate install.sh's embedded copy of bin/model-peer
Smoke tests use stub CLIs. They do not contact Anthropic, OpenAI, or Google and do not consume model usage.
The duplication invariant
install.sh must work standalone when piped from curl, so it carries a verbatim
copy of bin/model-peer inside a heredoc.
Any change to bin/model-peer must be followed by make sync.
make check-sync runs as a prerequisite of make test and fails the build
otherwise, and the smoke tests independently install and cmp the result. This is
the single easiest way to break the repository.
Releasing
VERSION is the source of truth, but the string is duplicated in the CLI banner,
the installer, the pinned curl URLs in the docs, and a smoke-test assertion:
tools/bump-version.sh <major.minor.patch>
That rewrites every occurrence, regenerates install.sh's embedded copy and then sweeps every tracked file for
the old string, failing if one survives. The sweep is deliberately broader than the
list of files the script rewrites, so a version pinned in a page nobody remembered
to add still fails the bump rather than shipping stale. CHANGELOG.md and the
lockfile are excluded on purpose: old releases keep their own numbers, and npm
dependency versions are not ours.
Write release examples as <major.minor.patch> rather than a real version, so a
documentation snippet is never mistaken for a pin. Date the CHANGELOG.md entry, then tag the merge
commit on main.
Portability
Target Bash 3.2 — that is what macOS ships, and CI runs macOS as well as Ubuntu. Avoid:
declare -A(associative arrays)${var^^}/${var,,}case conversionread -twith fractional seconds- bare
"${arr[@]}"on possibly-empty arrays — use${arr[@]+"${arr[@]}"} wait -n— reviewer fan-in waits in requested order instead, which costs nothing because every worker has already been started- GNU
timeout,xargs -P, GNUparallel— none of them ship on macOS
Parallel reviewers
cmd_review starts every requested reviewer before waiting for any of them, then
waits for all of them to reach a terminal state before applying the partial-panel
and --strict rules. Parallelism changes when independent reviewers run, never
what they receive, what they may do, or how their results are judged.
The process bookkeeping is where this is easy to get subtly wrong:
- Each worker writes its own file.
run_provider ... > "$tmpdir/$p.txt", never atee. A backgrounded pipeline putsPIPESTATUSsomewhere the parent cannot read and lets several model responses share one stdout. - Replay is deterministic. Completed reviews are written to stdout after fan-in, in requested-model order. Completion order never reaches the user.
- A dropped reviewer's partial output is destroyed, not passed along. A truncated finding read as a complete review is worse than no review.
- Each worker leads its own process group (
set -maround the background call), so cleanup can signal one worker subtree as a unit. - Each worker installs its own traps. Bash resets inherited traps in a subshell,
which is what makes this safe — a worker running the parent's
mp_cleanupwould delete the shared temp directory out from under its siblings. The parent owns the temp directory and the worker registry; a worker owns exactly one vendor process tree. - A signalled worker forwards the signal downward.
run_with_limitpublishes the supervised process group inMP_LIMIT_CHILD_PID; killing only the worker shell would leave the vendor CLI running as an orphan holding an open model call. - INT and TERM re-raise. Cleaning up and falling through would continue into code whose temp directory has just been deleted.
The primary test is a concurrency barrier: the stubs record that they started and then block until every requested reviewer has done the same. Serial orchestration can never open it. That is far less flaky than asserting elapsed time on a shared runner, which the suite does only as secondary evidence.
Architecture notes
Every consultation funnels through run_provider — the user's, and every one the
broker performs on a peer's behalf. It is the single chokepoint that enforces the
guards, pushes the chain, and computes the depth budget before either handing off to
mp_broker_run or invoking run_claude / run_codex / run_gemini directly. Put
policy in run_provider, not in the per-provider runners — those exist only to
translate a finished prompt into one vendor's CLI flags, and they take no policy
arguments at all.
The broker should stay small. Its job is: receive request, validate request, execute
consultation, return result. It is a request parser and a state machine around
run_provider, and it must not grow into an autonomous multi-agent planner. If
solving a problem here requires turning Model Peer into a general agent runtime, the
design has gone too far.
Two rules that are easy to erode:
- The parser looks for the exact identifier issued for the current turn, and nothing else. There is no "find any block" path. A block carrying a stale, absent, or mismatched identifier is ordinary output: neither parsed nor stripped, and it survives into whatever the continuation replays. Model Peer reviews Model Peer, so this repository contains the protocol in its own source tree.
- The identifier is framing, not authority. It prevents accidental
interpretation of protocol-shaped text. It is not a security boundary and must not
be described as one — the boundary is
mp_broker_deny_reason, which decides whether the requested provider runs no matter what block a peer emits.
--timeout is a deadline resolved once per invocation (mp_begin_deadline), and
every hop derives its budget from what is left (mp_budget). Do not reintroduce a
per-call duration: it turned --depth 3 --timeout 600 into a possible 3000-second
operation.
New behavior needs a tests/smoke.sh assertion. The stubs make this nearly free, and
they can capture generated artifacts: the Gemini stub copies the generated policy
into the log so its deny rules can be asserted at every depth.
Docs site
The site in documentation/ is Docusaurus.
cd documentation
npm install
npm start # local dev server
npm run build # production build; fails on broken links
It deploys to GitHub Pages from main via .github/workflows/docs.yml.