Skip to content

Repository files navigation

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.

ossq queue — 10 candidates from 594 considered, split across a PR lane and an issue lane

git clone https://github.com/br9704/oss-contributions && cd oss-contributions
npm install && npm run ossq -- scan     # requires gh auth login, Node >= 22.18

Case 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

ci license: MIT node >= 22.18 tests: 213 passing writes to GitHub: never

What it does

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.

Architecture

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/&lt;iso&gt;.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
Loading

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.

How it was built

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.

Verification

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.

Usage

$ 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 wired

doctor 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.

Limitations

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.

Status

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.

License

MIT — see LICENSE.

Author

Bruno Jaamaa — brunojaamaa.dev · github.com/br9704

About

Finds and triages OSS contribution candidates into a weekly review queue. Never writes to GitHub — enforced by a guard module and 23 tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages