ossq scans twelve open-source repos every Monday, scores their open issues against one person's
actual skills, and writes a shortlist to a file. It has never opened a pull request, filed an issue,
posted a comment or requested an assignment — and src/guard.ts makes it incapable of doing so.
git clone https://github.com/br9704/oss-contributions && cd oss-contributions
npm install && npm run ossq -- scan # requires gh auth login, Node >= 22.18Case study: brunojaamaa.dev/projects/oss-contributions
| 594 candidates considered, 10 queued | one weekly scan of 12 repos | scan-summary.json |
| 1430 of 2287 fetched "issues" were pull requests | 63% of the endpoint's output is noise | exclusions.json |
| 76 API requests cold, 0 warm | conditional requests make the re-scan free | ledger.jsonl |
| 0 failures, 0 backoffs | a full live read of all 12 repos | live-check.txt |
| 213 tests, 23 of them on the guard alone | the guard test is the spine | test-run.txt |
Through 2025 and 2026, maintainers were buried in AI-generated contributions and responded with
policy. curl killed its bug bounty in January 2026 after fabricated reports pushed valid submissions
below roughly 5%. QEMU, NetBSD, Gentoo and Zig banned AI contributions outright. tldraw auto-closes
external PRs by default. modelcontextprotocol/python-sdk bans from the org for it, in those words:
"If your agent is auto-filing PRs against our open issues, stop." LangChain called mass automated
contribution "a denial-of-service attack on our human effort."
So an agent that opens pull requests is the exact pattern being banned. This is the other one. ossq
reads GitHub, ranks what it finds, and writes CANDIDATES.md. A human reads that file and decides.
The output is a shortlist, not an action, and the boundary is enforced in a choke point every request
passes through rather than in an instruction that can be forgotten: any HTTP method that is not GET
or HEAD throws, any host that is not GitHub throws, and gh may only run from a read-only allowlist.
Twenty-three tests hold that line, and the project's stated rule is that if
test/guard.test.ts ever fails, nothing else in the repo matters.
The second thing it does is refuse to treat every repo the same, because in 2026 they are not. The
best contribution to modelcontextprotocol/inspector is not a patch. Their CONTRIBUTING says
"We accept issues, not pull requests. Design and implementation are done by the maintainers. If
you've already built a fix or feature locally, share the prompt you used to produce it — not the
source code" — and it applies to everyone outside the maintainer team, "including organization
members who happen to have write access." vercel/ai says "the most helpful contribution is often
a high-quality issue rather than a complete pull request" and credits the issue author with
co-authorship on the resulting fix. PrefectHQ/fastmcp heads a section "The best contribution is a
great issue" and treats comments claiming a task as the most common form of noise it receives. So
candidates are routed into two lanes — one where a merged PR is reachable, one where the
contribution is the issue — and each candidate prints beside it the rules of the repo it came
from, because violating one of them is an instant close.
Those rules are data, not prose. watchlist.json carries each repo's lane, PR
policy, claiming-comment rule, disclosure requirement, label rules, auto-close triggers, CLA
status, a source URL and the date it was last verified. PLAYBOOK.md is generated
from that file, and a test regenerates it and compares byte-for-byte, so the documentation cannot
drift from what the scanner actually enforces. The contribution templates are tested the same way:
the test derives each repo's requirements from the watchlist and asserts the prose carries them, so
a disclosure requirement cannot quietly fall out of a template while the queue still promises it.
Ranking is eight signals, each independently testable and each carrying the sentence that explains
its own points — protocol-level MCP work scores highest, then lane fit, freshness, whether anyone
has commented, whether a reproduction is present, whether the owner actually uses the tool,
engagement, and label gates. Every candidate in CANDIDATES.md shows the arithmetic that produced
its number. The queue caps at ten with three slots reserved for the issue lane, and no repo may take
more than two, because volume is the spam signal and a queue that offers one repo is not a queue.
flowchart TB
WL["watchlist.json<br/>12 repos: lane, policy, verifiedAt"]
SCHEMA["policy/schema.ts + load.ts<br/>typed, validated, staleness-checked"]
PB["PLAYBOOK.md<br/>generated, byte-compared in a test"]
CLI["ossq scan"]
SCAN["scan/scan.ts<br/>paginate to 60 non-PR issues<br/>drop assigned, avoid-labelled, contested"]
CAND["candidate.ts<br/>one normalised shape"]
SCORE["score/signals.ts + index.ts<br/>8 signals, cap 10, issue-lane floor, per-repo cap 2"]
GUARD{{"guard.ts<br/>GET and HEAD only<br/>github.com only<br/>gh allowlist only"}}
GOV["github/governor.ts<br/>core 120ms, search 10s<br/>backoff, reserve last 100"]
CACHE["github/cache.ts<br/>ETag on disk, corrupt entry is a miss"]
GH[("GitHub REST API")]
MD["CANDIDATES.md<br/>the deliverable"]
STORE["runs/queue.json<br/>runs/ledger.jsonl<br/>runs/<iso>.json"]
TERM["ossq queue<br/>terminal render"]
SEG["segment/ccline.mjs<br/>statusline: oss 10"]
HUMAN(["a human reads it<br/>and decides"])
WL --> SCHEMA --> PB
SCHEMA --> SCAN
CLI --> SCAN --> CAND --> SCORE
SCAN -->|"every request"| GUARD
GUARD --> GOV --> CACHE --> GH
SCORE --> MD
SCORE --> STORE
STORE --> TERM
STORE --> SEG
MD --> HUMAN
TERM --> HUMAN
SEG --> HUMAN
style GUARD stroke-width:3px
Two decisions carry the design. The first is that the guard is a module every request routes
through, not a rule in a prompt: guardedFetch forces the method, counts the call so a test can
assert a code path touched the network zero times, and refuses gh api with field flags unless
-X GET is explicit — because gh api <path> -f k=v is silently a POST, and that is the exact
shape of the search commands in the original brief. The second is that policy lives in a validated
data file that generates its own documentation. Both exist because the failure they prevent is
quiet: an agent that starts writing, or a playbook that promises a rule the scanner stopped
enforcing, would each look fine right up until the contribution is closed on sight.
Core REST plus conditional requests is the primary path, with GitHub's search API as a narrow,
throttled supplement. That inversion was measured, not assumed: search/issues tripped a secondary
rate limit after roughly three calls even spaced four seconds apart, while core sat at 4997 of 5000.
The plan's first locked decision was wrong, and reading the CONTRIBUTING files is what found it. The
brief named modelcontextprotocol/inspector as the entry repo and the goal as merged pull requests;
inspector does not accept pull requests from anyone outside the maintainer team, including org
members with write access. Two more of the five target repos preferred issues to patches. So the
success metric changed from "1–2 merged PRs a month" to "1–2 landed contributions", where a merged
PR, a maintainer-shipped fix on an issue you filed, and a reproduced prompt submission all count —
and the dual-lane model exists because of it. Four other policies came back materially stricter than
the brief assumed, including one that spells its label good-first-issue with hyphens, so a query
written for good first issue silently finds nothing.
Three things were confidently wrong and were only caught by running against live data. The REST
issues endpoint returns pull requests as issues — 1430 of 2287 fetched, 63% — so the first
implementation pulled one page per repo and produced three candidates from vercel/ai; adaptive
pagination toward a target of non-PR issues took the whole run from 332 to 604 candidates.
Freshness was computed from max(firstSeenAt, createdAt) on the theory that "fresh" meant fresh to
the queue, which made the first run describe an issue opened five months earlier as "surfaced
within the last week". And the zero-auth issues.atom fallback, which the brief described as
universally available, is dead — HTTP 406 on every repo tested, with any accept header or none,
while releases.atom and commits/main.atom still return 200. The unit tests passed throughout,
because they parse a synthetic fixture. Only the live run found it.
Then the scoring was measured rather than trusted, and two of the eight signals turned out to be
constants wearing a weight. "Few comments" awarded full marks to 81% of the pool. The MCP
keyword list contained "tool", "prompt" and "resource" — words in nearly every issue in an MCP repo
— so the heaviest signal in the system fired almost universally; replacing them with wire-protocol
vocabulary (server/discover, tools/call, _meta, streamable http, jsonrpc) visibly changed
the queue, which now leads with protocol bugs instead of "Log exceptions in tool calls". Neither was
findable from the top ten, where everything scores high by construction, which is why
signal-spread.ts measures the whole pool. The first live queue was
also seven candidates from one repo, since most signals are repo-level and ties broke
alphabetically; the per-repo cap of two exists because of that run.
The documentation pass found three more, one of them on the first public push. CI runs a step
asserting the statusline segment is fail-soft — garbage on stdin must produce exit 0 and an empty
stderr — and it failed immediately, because ccline-cli is a peer dependency that exists as a local
symlink here and is simply absent in a clean checkout. A static import of a missing module is an
uncatchable resolution failure that exits 1 with a stack trace on stderr, which is precisely what
the inherited invariant forbids. The segment had only ever been fail-soft on a machine where its
dependency was installed. Every import is now dynamic and inside the try. scripts/ also sat
outside the typecheck despite holding two npm entry points, and widening tsconfig.json to include
it surfaced a dead import on the first run.
And ossq scan grew its own output directories forever — runs/ had reached 6.1 MB and .cache/
22 MB — so the scan now keeps its newest twelve run artifacts and drops cache entries older than
thirty days, fail-soft, because a scan that cannot tidy up is still a good scan.
The full account, sprint by sprint, with every acceptance gate and its result, is in masterplan.md. It records what was deferred and why, including the parts that were attempted and refused.
npx tsc --noEmit exits 0 and npm test passes 213 tests across 12 files in about 300ms, with
zero network calls — the GitHub client is exercised against stubs, and the guard's call counter is
asserted at zero for the paths that must never touch the network. Verbatim output:
docs/evidence/test-run.txt.
Three suites carry invariants rather than behaviour. test/guard.test.ts —
23 tests — tries every non-GET method, every non-GitHub host, and every shape of gh command that
would write. test/policy.test.ts regenerates PLAYBOOK.md from
watchlist.json and compares it byte-for-byte with the committed file.
test/templates.test.ts derives each repo's requirements from the
watchlist and asserts the templates state them in prose.
Against live GitHub, npm run live-check reads the entire watchlist and reports 12 core requests,
13 budget units spent, 0 backoffs, 0 failures, 4941 of 5000 remaining
(live-check.txt). Budget is measured from x-ratelimit-* response
headers, not from GitHub's /rate_limit endpoint — that endpoint was measured to be a lagging view,
reporting remaining: 4810 and not moving across a dozen real requests while the headers on those
same responses decremented correctly.
A full scan considers 594 candidates from 2287 fetched across 12 repos in 58 seconds and 76
requests; a scan repeated six minutes after a cold one costs 0 requests
(scan-summary.json, ledger.jsonl).
Score spread across the pool after tuning: min 54, p25 77, p50 88, p75 100, max 125, and the whole
distribution moves by a point or two between days
(signal-spread.txt).
Policy drift detection is not theoretical: the scan of 2026-08-15 reported that
modelcontextprotocol/python-sdk had changed its CONTRIBUTING since the previous day's run, by
comparing the file's SHA between runs. The queue printed a re-read warning above its footer. The
scanner does not try to interpret the change — it says the ground moved and names the repo.
$ npm run ossq -- doctor
ok gh token present, read from the gh keyring at scan time
ok node v24.12.0 — strips types without a flag
ok policies 12 repos, all within their re-verify window
ok queue 10 candidates, scanned 0h ago (7 PR lane, 3 issue lane)
ok CANDIDATES.md present
ok weekly job dev.jaamaa.ossq installed — Monday 09:00 local
ok ccline segment ccline-cli resolvable — `segment/ccline.mjs` can render
ok cache warm — conditional requests make a re-scan nearly free
> last scan 2026-08-15 09:06 queued 10
> all surfaces wireddoctor exits 0 on warnings and 1 only on failures: a fresh clone legitimately has no queue and no
scheduled job, and making that an error would train the exit code out of meaning anything. On a
fresh clone the weekly job reads warn not installed — run ./scripts/launchd/install.sh.
rules prints a repo's actual policy, rendered from watchlist.json — the same data the scanner
filters on. Abridged, but otherwise verbatim:
$ npm run ossq -- rules --repo modelcontextprotocol/inspector
### `modelcontextprotocol/inspector`
**Issues only. Maintainers open every PR. If you fixed it locally, send the prompt — not the diff.**
| | |
|---|---|
| Lane | Issue lane |
| PR policy | maintainers-only — outsiders do not open PRs here |
| Claiming comments | not applicable — there is no outsider PR lane |
| AI disclosure | not required by the project (disclose anyway if asked) |
| Version label required | `v1` or `v2` |
| Personal use | Bruno runs this — his own bug reports here are first-hand |
> We accept issues, not pull requests. Design and implementation are done by the
> maintainers. If you've already built a fix or feature locally, share the prompt
> you used to produce it — not the source code.
**Gets closed on sight:**
- an unsolicited pull request from a non-maintainer
- a blank issue — the chooser is enforced, only Bug report and Feature request exist
[... label table and notes ...]
- The policy applies even to org members with write access — it is about the workflow, not permissions.
- The door in is the MCP Contributor Discord, channel #inspector-dev, plus the community calls
at meet.modelcontextprotocol.io — that is the documented path toward maintainership.
- Had 54 open unassigned issues and zero `good first issue` at the time of verification — label
filtering finds nothing here, which is why the scanner must not depend on labels.| Command | What it does |
|---|---|
ossq scan |
Read the watchlist, score, write CANDIDATES.md and the run artifact |
ossq queue |
Render the stored queue; --json emits it |
ossq rules |
The whole playbook; --repo <owner/name> for one; --write regenerates PLAYBOOK.md |
ossq doctor |
Token, Node, policy freshness, queue, schedule, segment, cache |
| Flag | Applies to | Effect |
|---|---|---|
--json |
global, queue |
Machine-readable; implies no colour and no staging |
--no-anim |
global | Print instantly; automatic when not a TTY |
--quiet |
scan |
No staging, no notification — for the weekly scheduled run |
--no-cache |
scan |
Ignore the local cache and re-fetch |
--linked-pr-budget <n> |
scan |
How many candidates get their linked-PR status resolved (default 40) |
All colour and all staging switch off when stdout is not a TTY, or under NO_COLOR or CI. Piped
output is plain and instant.
Optional: segment/ccline.mjs renders the queue as a ccline
statusline segment — oss 10 in green, grey once the queue is more than eight days old, and silent
when there is no queue at all. It reads one local file and never touches the network in its render
path, which is asserted by the guard's call counter.
scripts/launchd/install.sh installs the Monday 09:00 local scan.
Maintainer responsiveness is not measured. The design called for median time-to-first-maintainer -response, which needs the comments of every recent issue plus each commenter's association — hundreds of requests per repo per week for a tiebreaker. The shipped substitute is the fraction of open issues carrying at least one comment, which counts non-maintainer noise, so a chatty community with absent maintainers scores higher than it deserves. It is weighted lowest of the eight signals and refuses to score below a sample of five.
Two signals are near-constants, kept deliberately. Lane fit awards full marks to 91% of the pool
because ten of twelve repos have a PR lane, and the label gate fires on 9% because the labels it
reads are rare. Both are documented as such in src/score/signals.ts rather than removed — the two
lanes print side by side with their scores, and without lane fit those numbers would not be
comparable.
A token is effectively required. The zero-auth atom fallback is wired, unit-tested and dead:
GitHub returns 406 for issues.atom. The module is kept because it costs nothing and would resume
working, but it now reports why it failed rather than returning an empty list.
The heuristic has never been validated against outcomes. No queued candidate has yet become a merged PR or a maintainer-shipped fix, so there is no evidence that a score of 125 predicts anything at all. Tracking outcomes and feeding them back is the top item in the backlog, and it is the only signal that would show whether any of this works.
Twelve repos, one person's skills. Scoring is tuned to one contributor's shipped experience —
MCP protocol work scores highest because that is what he has built. It is not a general-purpose
issue recommender, and the watchlist is hand-curated with dated policy readings that go stale;
staleAfterDays flags them, but re-verifying is a human re-reading a CONTRIBUTING file.
Policy drift detection is a SHA comparison. A repo that rewrites its CONTRIBUTING is flagged for re-reading; a repo that changes its practice without changing the file is not.
The engine is complete and running: sprints 0 through 9 shipped scan · queue · rules · doctor, a
scored dual-lane queue, a ccline statusline segment and the contribution templates, with 213 tests
green and zero 403s across full live scans. The weekly job is installed — dev.jaamaa.ossq, Monday
09:00 local — and ossq doctor reports all eight surfaces wired. What remains is
Sprint 10 in the masterplan,
and every item left in it belongs to a person rather than to the tool: joining the MCP Contributor
Discord, attending a community call, and submitting the first contribution.
That last one is staged rather than done. The top candidate,
typescript-sdk#2619, has
been reproduced from source and fixed — nine tests written failing first, then passing, with that
project's own client suite going from 785 to 794 passing and no regressions.
It has not been submitted, and this tool cannot submit it. That is the design working rather
than a gap in it.
MIT — see LICENSE.
Bruno Jaamaa — brunojaamaa.dev · github.com/br9704