Working name: opensource-compass. Local directory is opensource-navigator.
A personal, single-user tool that answers one question: "should I spend my next five hours on this open-source issue?" CLI, a JSON API, and a React UI. No LLM anywhere. ~14,400 lines of TypeScript, 229 tests.
This document is the full context for continuing development in a fresh conversation.
Built and working:
| Slice | What it does | Status |
|---|---|---|
| 1 | Repo + issue corpus in Postgres, incremental sync | done |
| 2 | Maintainer responsiveness metrics (GraphQL) | done, 4 calibration rounds |
| 3 | Setup complexity from files at HEAD | done, 2 calibration rounds |
| 4 | Ranked shortlist + evidence panel | done, 3 calibration rounds |
| 5 | Decisions journal (decide / journal) |
done, zero rows recorded so far |
| — | prune (pause repos not worth syncing) |
done |
| 6 | JSON API over Slices 4–5 (serve) |
done |
| 7 | React UI: shortlist, why, decide, journal | done, verified end to end |
| 8 | Skills profile (§9a) + reputation gate (§9c) | done, verified end to end |
| 9 | Pagination, language picker, sync-from-UI | done, verified end to end |
| 10 | add, stack filter, first-run guidance, full-tree setup |
done, verified end to end |
Live data as of handoff:
- 1,076 repos (1,072 active, 4 paused)
- ~86,700 issues
- repo_metrics for 1,072 repos (~500 responsive, ~190 dormant, ~160 slow, ~150 unknown, ~60 moderate)
- setup_facts for only 50 repos ← see §8, this biases the ranking badly
- decisions: 0 rows ← the thing that would make any weight defensible
- ~14 large repos still resuming their issue backfill page by page
HANDOFF-NEXT.md in the root supersedes this file for anyone picking the project up cold. It carries
the current state, the strategic reframe (org-first discovery, the GSoC wedge, personal rather than a
platform), and the phase plan. docs/roadmap.md has the phase detail.
This file is kept for the per-slice history and the accumulated GitHub API lore, which is still the most expensive knowledge here to rediscover.
This file is the working document for resuming mid-project. User-facing documentation now lives
separately, and it is the place to correct a factual error — this file is notes, docs/ is the
contract:
README.md— installation and first run, written for someone new to the projectdocs/getting-started.md— full walkthroughdocs/how-ranking-works.md— every signal and weight, measured versus assumeddocs/cli-reference.md,docs/api-reference.md,docs/configuration.md,docs/database.mddocs/architecture.md,docs/development.md— the layering rules and conventionsdocs/roadmap.md— supersedes §9 below for anyone who is not resuming a sessiondocs/design-notes.md— the original README, kept for its rationale
The docs are checked against the source, not just proofread: internal links and anchors, every npm
script and CLI flag, every documented endpoint against server.ts, the verdict list against view.ts,
twenty weight values against weights.ts, and the ±25 preference cap against profile.ts. npm run docs:check runs it. Re-run
it after changing a weight, a flag, an endpoint or a migration — it has caught four real problems so
far, including a flag that was documented before it existed. Writing a doc that quietly stops being
true is worse than having no doc.
Requires Node ≥ 22.18 (runs TypeScript natively, no build step) and Postgres 16.
cp .env.example .env # DATABASE_URL, GITHUB_TOKEN (read-only public repo access, no scopes)
npm install
npm run compass -- migrate
npm test # 166 tests
npm run typecheckCommands:
migrate
seed [--dry-run] [--only id,id] [--max-pages N]
sync repos|issues|metrics|setup|all [--limit N] [--repo owner/name] [--stale-days N] ...
status
maintainers [--sort median|ignored|stale|reviewed|merge] [--min-prs N] [--bucket X]
explain owner/name per-PR evidence behind one repo's metrics
responders [--repo owner/name] who answers external PRs; exposes bot first-responders
setup [--sort weight|services|env|runtime] [--weight X] [--max-services N]
shortlist [--limit 20] [--min-score 20] [--per-repo 2] [--language X] [--labelled]
[--max-setup light|moderate] [--min-stars N] [--max-stars N] [--include-dormant]
why owner/name#123 itemised score breakdown
decide owner/name#123 <verdict> [--hours N] [--actual-hours N] [--reason "..."]
journal [--limit N]
prune [--dormant] [--heavy] [--min-confidence medium|high] [--apply] [--unpause]
serve [--port 8787] [--host 127.0.0.1]
serve starts a Fastify JSON API on localhost. Query parameters are named after the CLI flags so a
failing URL transcribes into a command line without a translation table.
GET /api/shortlist?limit=20&min-score=20&per-repo=2&language=X&labelled&max-setup=moderate
&min-stars=N&max-stars=N&include-dormant&fetch-limit=N
GET /api/issues/:owner/:name/:number/why
GET /api/journal?limit=30
POST /api/decisions {"ref":"owner/name#123","verdict":"started","predictedHours":4}
GET /api/verdicts /api/health
No auth, bound to 127.0.0.1. POST /api/decisions writes; do not bind 0.0.0.0 without adding auth
first. COMPASS_HOST / COMPASS_PORT override.
npm run compass:tsx and npm run test:tsx exist as fallbacks for older Node.
migrations/ 001..008, plain SQL, applied by a tiny runner
fixtures/ dev_corpus.sql — offline corpus for the diff-verification recipe
web/ React + Vite frontend; its own package.json, builds to public/
src/api.ts typed client; query-param names mirror the CLI flags
src/components.tsx stepped meters, provenance bar, evidence chips, ledger
src/Shortlist.tsx filter rail, ranked rows, inline `why` breakdown
src/Journal.tsx decision trail and the calibration figure
src/DecideDialog.tsx lifecycle-aware capture: predictions on open, outcomes on close
public/ Vite build output, gitignored; Fastify serves it when present
src/
cli.ts parseArgs dispatch; no process.exit (it truncates stdout)
params.ts PURE string->value coercion, shared by the CLI and the HTTP query parser
config.ts env loading; COMPASS_IGNORE_LOGINS
db.ts pg pool, bulkUpsert, jsonb(), stripNul()
migrate.ts
status.ts corpus counts, coverage, run history, invariant checks
prune.ts
report.ts maintainers / explain / responders / setup reports
github/
rest.ts REST client: ETag, pagination, per-resource rate budget, retries
graphql.ts GraphQL client: batched aliases, partial-error handling, real cost accounting
types.ts
metrics/
compute.ts PURE responsiveness statistics + bot/insider classification
setup/
parse.ts PURE file parsers (compose, runtimes, env, CI) + weight classifier
seeds/queries.ts the corpus definition — edit this file
sync/
map.ts payload -> row mappers
run.ts sync_runs bookkeeping, RUN_KINDS
seed.ts repos.ts issues.ts metrics.ts metrics_query.ts setup.ts setup_query.ts
setup/
parse.ts PURE file content -> setup facts
stack.ts PURE framework detection from manifests + declared topic aliases
sync/
tree.ts recursive tree fetch + PURE path classification
add.ts add a project by name, then make it rankable
rank/
weights.ts EVERY ranking tunable, with rationale — still the DEFAULTS under a profile
profile.ts PURE: profile shape, validation, and default resolution
score.ts PURE scoring -> itemised breakdown
candidates.ts candidate SQL (hard gates)
view.ts PURE presentation models: per-repo cap, summaries, journal pairing
data.ts queries -> view models; no formatting
render.ts terminal output; every value arrives already computed
http/
server.ts Fastify; query parsing and status-code mapping only
jobs.ts background sync runner: one at a time, no cancellation, token checked first
The layering rule, and why it is worth enforcing. rank/report.ts used to query Postgres, apply
judgement, and format terminal output in one function. The judgement was therefore untestable: the
per-repo cap and the journal's accuracy threshold could only be reached by running the command
against a live database and reading the text. The split is:
view.tsmay not importdb.ts. Anything with judgement in it lives here, under test against fixtures. Same rule asmetrics/compute.tsandsetup/parse.ts.data.tsmay not format. An empty candidate set is a returned value carrying a notice, not an earlyconsole.logand a barereturn. That is what made the empty cases reachable from HTTP.- Renderers own their prose. Notices are structured kinds (
no-candidates,none-scoring,fetch-cap-hit), because the CLI's remedy is a flag to retype and the UI's is a control to move. A renderer may never decide to show a number the view withheld — seeMIN_PAIRS_FOR_MEAN.
report.ts (maintainers / responders / explain / setup) has not had this treatment yet. It is
the same mechanical change repeated four times.
Deliberate stack decisions, all documented in the README:
pg+ plain SQL migrations, not Prisma. The sync does multi-rowON CONFLICTupserts. When the UI needs an ORM,prisma db pullintrospects cleanly.- No Redis, BullMQ, NestJS, Docker Compose. A cron entry provides everything needed.
- REST for issues, GraphQL for metrics and setup. REST because
since=gives incrementality; GraphQL because per-PR review data over REST would need ~40,000 requests against a 5,000/hr limit. - Pure modules for anything with judgement in it (
metrics/compute.ts,setup/parse.ts,rank/score.ts) so it is testable without network or database.
repos— corpus, sync watermarks (issues_synced_at,issues_backfill_page,meta_etag),sync_statein (active, paused, gone), verbatim payload inrawjsonbissues— issues only (PRs filtered out),rawjsonbrepo_metrics— one row per repo, Slice 2 output, per-PR audit trail indetailsetup_facts— one row per repo, Slice 3 outputdecisions— the journal: verdict, predicted_hours, actual_hours, reasonsync_runs— every run, with rate-limit snapshot and error
Every repo/issue row keeps the raw API payload. You will change your mind about which fields matter;
re-fetching is expensive and reshaping from raw is free.
These are the non-negotiables. They were arrived at by hitting real problems, and abandoning them is how this becomes a system that looks authoritative and lies.
- No fabricated precision. The original spec wanted "Overall Confidence: 86%" and "Estimated
setup: 35 minutes". Neither is measurable. Verdicts are ordinal (
dormant | slow | moderate | responsive,light | moderate | heavy), sample size is reported as a confidence bucket, and the ranking score explicitly "has no units and predicts nothing". - The evidence panel is the product, not the score. Every ranking line carries the raw value that produced it. A rank you cannot interrogate is a rank you cannot correct.
nullnever means0. An unmeasured compose service count is—, not zero. Reporting absence as a finding makes unreadable repos look simple.- Hard gates eliminate, weights rank. An assigned issue is not a weak candidate, it is somebody else's work — excluded in SQL, not penalised in the score.
- Prefer under-flagging to over-flagging when the costs are asymmetric. A missed bot inflates one median; a human misclassified as a bot makes an active project look dead.
- Every expensive operation is resumable and budget-aware. Watermarks only advance on success; runs abort cleanly at a reserve floor rather than dying mid-repo.
- Per-item error isolation. One bad repo must never discard a 1,000-repo run.
Every one of these came from running against live data, not from reasoning. They are encoded in tests.
Slice 2, maintainer responsiveness:
median_hours_to_responseis right-censored: computed only over PRs that got a reply. A dead repo where 2 of 40 got fast replies has an excellent median. Dormancy is therefore checked before speed, andno_response_rateis printed beside the median everywhere.- A merge is attention. Repos that squash-merge without commenting scored 100% ignored alongside
100% merge rate.
mergedByis fetched so an automated merge queue doesn't count as a human. - A PR opened this week is not evidence of neglect.
grace_days(7) excludes young unanswered PRs from the ignore-rate denominator (decidable_prs). authorAssociationis not a maintainer test. It only reportsMEMBERfor public org membership. Whole organisations (EleutherAI, Uniswap, jupyter, ossf) read as 0 responses on 40 PRs. Fixed with a per-repo maintainer roster fromassignableUsers+ anyone who merged + anyone the API did label an insider.- Comments are a sign of life. Prow projects (Kubernetes) approve via
/lgtmcomments and let a bot merge; a review-only liveness signal marked them all dormant at a 0% ignore rate. - Bot detection is a minefield.
/bot$/matched the humansklembot,abbot,talbot,elliotbot. Suffix matching now requires a separator; known automation is named explicitly; therespondersreport finds the rest empirically viaCOMPASS_IGNORE_LOGINS. - The roster promotes service accounts.
mattermost-buildhas write access, so it became a "maintainer" answering in 0h.-build,-deploy,-release,-runner,-jenkinsadded. - A tiny open-PR denominator can force false dormancy. Stale-backlog dormancy now needs ≥5 open PRs.
- A median over 1–2 responses is not a measurement.
respondedPrs < 3returnsunknown. - A merge counts as attention whoever pressed the button. An earlier version ignored merges
performed by automation. That broke every Prow-based project — Kubernetes approves via
/lgtmcomments and letsk8s-ci-robotmerge — so kueue reported 19 of 21 external PRs "too recent to judge" while 11 had already merged. The guard was redundant: bot-authored PRs are excluded upstream, so anything reaching that check is an outside human's work. Queue merges are labelledMERGED_BY_QUEUEin the audit trail. comments(first: 5)is thin for Prow-heavy repos, where CI bots fill the early comments and the human/lgtmlands later. Not yet addressed; the merge fix above covers the dominant symptom.- Merge rate is NOT in the responsiveness bucket, on purpose. Jenkins answers every outside PR
within 2h and closes 10 of 16 unmerged. That is legitimately
responsive(someone is home) and a bad place to spend five hours. The two facts stay separate; merge rate is weighted in Slice 4.
Slice 3, setup complexity:
- Unresolved Maven/Gradle placeholders (
${java.version}) were reported as versions. .tool-versionslists linters and test harnesses, not just runtimes — filtered to a runtime allowlist.confluentinc/cp-zookeeperdidn't matchzookeeperbecause the pattern anchored on/. Images are now normalised to the final path segment without tag/digest, then keyword-matched.- Unparseable workflow YAML must yield
nullfor "CI on PRs", notfalse.
Slice 4, ranking:
fetchLimitsilently capped candidates at 4,000, ranking whichever slice was most recently updated.- One repo took 12 of the top 20, all identically scored.
--per-repo(default 2) caps it. - An epic ranked second: "Master FR: Pen, Stylus, Handwriting…" with a
good first issuelabel.SCOPE_PATTERNSreads titles for FR/RFC/epic/umbrella/rewrite/decision markers; bodies over 5,000 chars read as specifications. - Every row showed the same three signals because repo weights dominate. Each score line now
records
about: 'repo' | 'issue'; the compact view shows issue lines only. - Issue mills. A small app with issue numbers in the 26,000s, titles like
[Good First Issue] Add new Video Game Quote 50, a dozen opened the same day. Every per-issue signal read as excellent — the pattern only exists across issues.buildRepoContextderives per-repo facts from the candidate set in memory; ≥8 invited issues within 7 days costs 35 points.
Found in Slice 10:
classifySetupWeightacceptedtreeTruncatedand never read it. Harmless only whiletreeTruncatedwas defined asfilesSeen === 0, so the two conditions coincided. Once the reading walked the real tree, GitHub's truncation on very large repositories would have produced a confidentlightfrom a partial listing — the same wrong answer the root-only fix existed to remove. Truncated now yieldsunknown. A parameter accepted and never read is a latent bug, not dead code.- An unrecognised
--stackreturned the entire corpus. The SQL inferred "a stack was requested" from the resolved arrays being non-empty, which is indistinguishable from an unknown term resolving to nothing. A separate boolean now carries the intent. A filter that silently does not filter is worse than one that errors. stack=javascriptreturned nothing whilestack=jsreturned everything. The alias map was narrow for one spelling and broad for the other. Both include TypeScript now;tsstays narrow because the implication runs one way.prune --dormantwould have undoneadd. A manually added project needsdiscovered_via = 'manual'protection, or the next prune quietly pauses the thing you asked for.- Backticks inside a SQL template literal terminate the string. A
--comment containing a backticked flag name brokeprune.ts, and the parser error pointed nowhere near the cause.
Found while adding the UI controls (Slice 9):
GITHUB_TOKENwas required to read the corpus.loadConfig()demanded it, so a database restored onto a machine without a token could not even runmigrateorshortlist— and the new "no token configured" screen was unreachable, because the server died before it could render it. The token is now checked byrequireGitHubToken()in the REST and GraphQL clients, where the network calls actually are. Reading the corpus needs Postgres and nothing else.- The language filter matched case-sensitively. Typing
typescriptreturned an empty shortlist that looked like a real answer. Matching is nowlower() = lower(), but the actual fix is/api/languagesand a picker: you cannot mistype a list. heldBackInRepodepended on the page size. It counted only what had been walked before the limit was reached, so the same repository read "+3 more" on one page size and "+7 more" on another. The walk now covers the whole ranked list and the page is a slice of the result.- A 202 with no immediate feedback invites a double-click. Waiting for the next poll left up to two seconds in which starting a sync visibly did nothing, which is long enough to press again and collect a 409. The 202's own body now seeds the running state.
Found by measuring at corpus scale (Slice 7):
whyscanned the whole corpus per expansion.loadOnefetched every candidate to rebuild the repository context, which is per-repo — so expanding one row cost as much as the entire ranking. At 86k issues that was ~2.6s per click. Scoping the query to one repository took it to ~14ms, andview.test.tsnow asserts the invariant that makes it sound: a repo's context is derived from its own issues alone. If a future signal reads across repositories, that test fails and the scoped query has to go back.- The 50,000-row fetch cap is reachable on a corpus this size. A synthetic 86k-issue corpus hit
it and the
fetch-cap-hitnotice fired, meaning the ranking saw a recency-ordered subset. The notice works; the cap may still need raising, andshortlisttakes 2–4s at that scale regardless. Nothing here is indexed for ranking yet — that is the obvious next performance move if it grates.
Found by the data/render split (Slice 6):
assembleShortlistnever threadednowintorankCandidates, so issue age and the issue-mill window scored against wall-clock time. Harmless in production, and invisible precisely because no fixture could be scored reproducibly without it. The clock is now injected everywhere.- A predicted
0hours gaveInfinityand rendered as "Infinityx your prediction".hoursRatioreturns null and the pair reads as incomplete. - Fastify types the
setErrorHandlerfirst argument asunknownunder these compiler flags; narrow it before reading.message. - A long-running command breaks the
main().then(() => closeDb())pattern — the pool would close under the listening socket.main()returns'listening'forserveand the teardown skips it.
Infrastructure bugs worth remembering:
process.exit()truncates buffered stdout. Never use it; setprocess.exitCode.TypeError: terminatedis undici aborting the response body stream — it throws at.json()/.text(), not atfetch(). Both must be inside the retry guard.- Postgres rejects NUL in
textand rejects the\u0000escape injsonb. Strip it in aJSON.stringifyreplacer, never with a regex over serialised output — a body containing the literal characters\u0000becomes invalid JSON. - Untyped bind parameters in operator expressions fail:
case when $3 >= $4gave "inconsistent types deduced for parameter $3".src/sql_params.test.tsstatically scans for this class. RUN_KINDSin TypeScript and thesync_runs.kindCHECK constraint drifted;src/sync/run.test.tsnow asserts they agree.- Import specifiers need explicit
./and.ts. A baresrc/cli.tsis a package specifier in ESM.
Used throughout; worth keeping:
npm test— 229 tests, mostly over the pure modulesnpm run typecheck— strict +noUncheckedIndexedAccess+exactOptionalPropertyTypes+erasableSyntaxOnly(the last guarantees Node can run the TS without transformation)- SQL is validated without a database using Python
pglast(libpg_query, the real Postgres parser):pip install pglast --break-system-packages, thenpglast.parse_sql(sql). Also used to cross-check that code column lists match the migration DDL. - GraphQL queries are validated against a hand-written stub schema with the
graphqlpackage, so a misspelled field fails innpm testrather than mid-run. - HTTP routes are tested through
app.inject(), no socket. Only the paths that fail validation before the data layer, plus the constant routes — a mocked pool would only assert that the mock behaves like the mock. - The UI is verified by rendering it, not by reading it. The built bundle is loaded into jsdom against a live server and asserted on: that it mounts, fetches, and renders rows; that every row has a provenance bar with to-scale segments; that meters render discrete cells; that a row expands into two ledgers whose subtotals sum to the total; that changing a filter refetches; that recording a decision removes the issue from the shortlist and makes it appear in the journal with its trail and hours; and that the calibration mean stays hidden at two complete pairs and appears at three. Scripts are throwaway, but the shape is worth repeating — a frontend that compiles is not a frontend that works, and every one of those assertions failed at least once while being written.
- Refactors are verified by diff against a fixture corpus, not by inspection. Postgres 16 in a
scratch directory,
migrate, ~18 fixture issues chosen to hit the interesting cases (a repo with a deep labelled backlog for the per-repo cap, an assigned issue, a locked issue, a dormant repo, an epic with a 6,000-character body), then the old and new implementations run side by side over nine command variations anddiffed. This caught nothing on the Slice 6 refactor, which is the point: it is what licensed the claim that behaviour was preserved. The corpus is committed atfixtures/dev_corpus.sqlwith a note on each row's purpose; reload it withdrop databaserather than truncating, sincedecisionsrows accumulate and change what the shortlist gates out.
Resolved during the final test pass (kept for context):
setup_factsnow covers all 1,072 repos (802 light, 224 moderate, 46 heavy). Before this, only 50 had facts and those 50 carried a ~30-point head start in the ranking.prune --applyhas run: 193 dormant medium/high repos paused, leaving 879 active. A further 7 became dormant in the metrics run afterwards and could be pruned again.
Still open:
- decisions has 3 issues recorded, 1 with both a prediction and an outcome (dulwich#1822 at 2.3x the estimate). Three complete pairs is where the accuracy figure starts meaning anything, and fifteen is where the weights become defensible.
- Paused repos keep frozen metrics. They are excluded from recomputation, so a stored value
reflects whatever the code did when it last ran.
statusnow scopes its invariant check to active repos for this reason. If a paused repo is ever unpaused, recompute it withsync metrics --repo owner/name. metadata stale >24hreached 1,076 —sync reposdefaults to--limit 1000, so it cannot cover the corpus in one pass.
Structural limitations:
- Slice 3 reads root-level files only. A compose file under
docker/orserver/reads as absent, somattermost/mattermostcomes outlight. Readlighton a large multi-component project as "not measured". Fixing it needsgit/trees?recursive=1per repo (~20% of hourly core budget) plus a second pass to fetch what's found. This is the highest-value remaining backend work. - The ranking can't read issues. Rows 15–20 differ by a label word and a comment count, which are proxies for issue quality, not measurements. Distinguishing two plausible tasks needs someone to read them. This is the one place an LLM would genuinely earn its place: one call per top-20 candidate returning a scope estimate and likely files, cached in a column. Effort estimation still isn't worth it.
- The 40-PR metrics window is small for busy repos. Mattermost had 37 insider PRs in its 40 most
recent, leaving 3 external — hence
confidence: low. Correct, but thin. - The journal aggregates per issue, not per row. Verdicts arrive over time (
started --hours 4, thenmerged --actual-hours 9), so a per-row view could never pair a prediction with its outcome. - The 7-day grace period excludes most PRs on very active repos. kueue showed 14 of 22 external PRs as "too recent to judge". Correct behaviour, but it means the busiest projects are judged on a small decided subset.
- A 200 response with an empty body was treated as a permanent failure ("unparseable JSON (0 bytes)") and killed a batch. Now retried like any other transient condition.
- The shortlist can return a monolingual list. With
--min-stars 1000, all 15 rows came back Python — the smaller TypeScript repos were filtered out. Use--languageif a specific stack is wanted. - The tool cannot detect an obsolete-but-open issue.
lightly#1945("Raise minimum supported Python version to 3.8") ranked 11th while a sibling issue in the same repo referred to that bump as already done. This is the clearest argument for the LLM read-the-issue step. --min-score 20filters ~5% of candidates. Effectively useless; the range is 20–111, median ~59.
Requested: enter desired skills → see best repos and issues for those skills → pick one → get clear local setup instructions → mark completed/merged. Plus a dashboard, and a reputation floor (stars ≥ 500–1,000).
Done: the API-ification refactor, the JSON API, and the shortlist / why / decide / journal
screens (§9 items 3 and 5, plus the whole suggested stack bar the profile). npm start builds the
frontend and serves it alongside the API on one origin.
Still to build. These are the genuinely new pieces rather than more rendering:
(a) A skills profile. Currently Done. Migration
007 adds a single-row LANGUAGE_POINTS is a hardcoded map.profile table; src/rank/profile.ts holds the pure shape, validation and
default resolution; scoreCandidate takes a ResolvedProfile as a fourth argument. Three rules are
load-bearing and each has a test:
- An empty profile scores identically to the pre-profile tool.
weights.tsremains the default, which is what let this ship without re-tuning anything. - Languages replace wholesale, they do not merge. Deleting TypeScript in the settings screen has to mean it stops scoring, not that it quietly reverts to 14.
- Preferences are capped at ±25 (
MAX_PREFERENCE_POINTS). The largest measured weight is 22 (responsiveness); a preference that outranks every measurement turns the ranking into a filter, and the shortlist already has real filters. The API returns 400 with that reasoning in the message.
Topics match repos.topics (GIN-indexed since 001) and pay once, at the best rate — otherwise a
repo tagged react + frontend + typescript collects three payments for one fact about itself.
profile.min_stars / max_stars / max_setup_weight supply shortlist defaults that an explicit
request still overrides, so a thin shortlist can be widened for one look without editing what is
saved.
(b) A setup-instructions generator. setup_facts already holds the raw material — runtimes and
versions, compose service count and names, backing services, env var count and template path, task
runner, devcontainer, CI-on-PR. Turning that into an ordered checklist ("install Node ≥22 and Docker;
cp .env.example .env and fill 14 variables; docker compose up starts 7 services including
Postgres and Kafka; make test") is deterministic template work. Do not let it emit invented
minute estimates — that's the false-precision failure the whole project has been avoiding. Note
that this depends on fixing the root-only limitation first, or the instructions will be wrong for
exactly the complex projects where they matter most.
(c) Reputation gate. Promote Done — it is in the
profile and surfaced on the settings screen, with the rationale in the copy: below ~500 stars
abandonment risk dominates, above ~50k the labelled beginner issues are claimed within hours. No
default is set, deliberately: picking 500 for you would be a preference asserted as a measurement,
and --min-stars to a profile setting.seeds/queries.ts already biases the corpus toward the 1k–30k band.
(e) Sync from the UI. Done. POST /api/sync/:kind starts one of the five scans and returns
202; GET /api/sync reports the active job, the corpus counts, and recent runs. Three constraints
are deliberate and each is visible in the interface:
- One at a time, guarded in-process. Syncs share one hourly GitHub budget and
reposwould write the same rows twice. The lock cannot see a CLI run in another terminal, sorunningElsewherereports those separately rather than pretending to guard them. - No cancellation. A stop button that cannot interrupt an in-flight request would be a lie. The screen says so. Budget exhaustion still stops a run cleanly and watermarks make it resumable.
- The token is checked before starting, so a missing one is a 503 with a fix rather than a failed run in the history.
withSyncRun now flushes its counters every three seconds, so a run in progress is observable from
outside the process — useful from a second terminal during a CLI run too. It is not a substitute
for a real job queue: a killed process still leaves a row at running forever, which is why the UI
reports those rows without claiming to know what they mean.
(d) A workflow view. The Partly done.
The journal screen renders the trail per issue, and decisions verdicts already model the lifecycle.DecideDialog captures predictions on opening
verdicts and outcomes on closing ones — the part that actually populates calibration. What does not
exist is the Kanban-ish board: columns by latestVerdict, drag to advance. Worth doing only if you
find yourself tracking several issues at once; with one or two in flight the list is better.
Suggested stack — deliberately lighter than the original spec's NestJS + Redis + BullMQ:
One small HTTP server in this same projectdone: Fastify,src/http/server.ts,serve. The four ranking endpoints exist. The four inreport.tsdo not yet.- React + TypeScript + Vite, TanStack Query. MUI is fine if wanted. Nothing frontend exists yet —
no
web/directory, no Vite config, no proxy. That is the next thing to create. - No auth for now — single user, localhost. GitHub OAuth belongs in the multi-user phase.
- Keep the CLI. It is the fastest way to debug, and the sync jobs should stay cron-driven. Keeping the HTTP query parameters named after the CLI flags is what makes that debugging path cheap; do not "tidy" them into camelCase.
The remaining half of that refactor: report.ts still mixes querying with terminal formatting
for maintainers, responders, explain and setup. Repeat the view.ts / data.ts /
render.ts split four times and those endpoints become trivial. Do it when a screen needs them —
the dashboard does, and the dashboard is explicitly not first.
Ordering suggestion, revised:
sync reposin two passes (see §8.3), thensync setup --limit 1200, thenprune, so the ranking is fair. This is data work on your machine and nothing else should happen first — every screen built before it will be built against a biased ranking.API-ification refactordone for the ranking slice.- The shortlist +
whyscreens against the existing endpoints.GET /api/shortlistalready returnssummary,rows(withevidence,context,heldBackInRepo) andnotices; render the notices, or the empty and fetch-capped states will silently look like "no results". - The profile (§9a), which is the first thing needing a migration (007) and a change to
scoreCandidate's inputs. - The workflow board (§9d) over
decisions. This is what populates the calibration data, so it outranks the setup checklist despite being less visibly useful. - The setup checklist (§9b), after the root-only fix in §8, or it will be wrong for exactly the complex projects where it matters.
Resist building the dashboard first — the shortlist and the decision capture are what make the tool
worth opening, and the dashboard is the only screen that needs the report.ts half of the refactor.
The parts backed by real data are Slices 2 and 3: they measure whether maintainers review outside work and what it costs to get a project running. Those went through six rounds of correction against a 1,000-repo corpus and the tests encode what was learned.
Slice 4's weights have had zero validation against outcomes. They are a preference function
written from reasoning, not a model fitted to anything. The score's job is to order candidates, and
why exists so you can disagree with any line.
Slice 6 added no measurement at all. It moved code so the judgement in it could be tested and served, and it verified that it moved nothing else — the shortlist you get today is the shortlist you got before, to the byte. Do not mistake 166 tests for 166 tests' worth of validated weights; most of the new ones assert that the assembly does what it says, not that what it says is right.
The single highest-value action is still not code: it is working three issues from the shortlist and
recording decide ... --hours N then decide ... --actual-hours M. Fifteen of those rows is what
turns weights.ts from assertion into measurement, and it is also the only thing that will say
whether an LLM reading issue bodies is worth the complexity. The workflow board in §9d exists to
make that capture a click instead of a command, which is the only reason it ranks above the setup
checklist.