Imported from justinbowes/starmap (
AGENTS.md). Install upstream withnpx skills add justinbowes/starmap. Copyright stays with the author.
Notes for coding agents
Conventions this repo has settled into. Read before non-trivial edits.
What this is
A 3D atlas of stars within 50 ly of Sol, designed to communicate the structure of the local stellar neighborhood symbolically and at a glance.
It is not a planetarium (no sky-from-Earth view, no ephemerides), not a spacecraft simulator (no time evolution, no orbits beyond a static snapshot), and not a constellation projector (constellations are a from-Earth artefact at odds with the galactic-Cartesian frame the atlas lives in). Features that nudge toward any of those framings are out of scope for v1; if a feature request can be implemented two ways, the one that strengthens "atlas" wins.
When in doubt, ask: "does this help someone understand where stars are and how they relate?" If yes, it belongs. If it's about when or how to fly there or what it looks like from a particular planet, it's a different project.
Commit cadence
Land work in small, themed commits. The author has noted a tendency to let work pile up uncommitted; agents should counterbalance that, not amplify it.
Heuristic: if you've finished a self-contained slice (a new module + its tests, a refactor that leaves the suite green, a spec edit), suggest a commit before continuing. A reasonable slice:
- Adds or modifies one logical concept.
- Leaves
pytest,pyright,npm test, andnpm run typecheckall green. - Has a one-line message you could write without re-reading the diff.
Don't batch unrelated changes "to save round-trips." Smaller commits make review and bisection cheaper than the typing time saved.
Testing pyramid
Four layers, each with a different right answer:
- Pure Python / TS unit tests. The bulk. Spatial math, spectral parsing, focus decision logic, name resolution. No I/O, no mocks beyond the bare minimum. Both languages have these.
- Component / scene tests.
pytestagainst an in-memory DuckDB for query functions; Vitest with@react-three/test-rendererfor R3F components. - HTTP / integration.
httpx.ASGITransportfor non-streaming endpoints (fast). Real uvicorn on a random localhost port for SSE —ASGITransportbuffers indefinite responses and will lie. Seetests/test_api_focus.pyfor the pattern. - End-to-end. Manual right now. Playwright + pixelmatch goldens are planned (SPEC) but not built.
When adding a feature, add tests at the lowest layer that locks the contract. A bug at layer N usually means a missing test at layer N-1.
Cross-language parity via shared fixtures
Anything that has a Python implementation and a JS port (currently:
parse_spectral_type, point_to_segment_distance, resolve_name) must have
a parity fixture under tests/fixtures/shared/, generated by
scripts/gen_fixtures.py. Both pytest and vitest consume the same JSON.
Workflow when adding a new parity-locked function:
- Add a
_my_fixture()builder toFIXTURESinscripts/gen_fixtures.py. - Run
uv run python scripts/gen_fixtures.py— commits the JSON. - Add a parametrized test in
tests/test_shared_fixtures.pyasserting the Python implementation reproduces the recordedexpected. - Add a Vitest test under
viewer/tests/shared/doing the same against the TS port. test_fixtures_not_stale(already present) catches "JSON drifted from Python" by re-running the generator with--check.
Never hand-edit files in tests/fixtures/shared/.
DuckDB read/write split
open_db(path) is read-only by default. Read-write goes through
open_db_for_write(path) and is reserved for starmap ingest and migrations.
This is what lets starmap serve, the MCP server, and ad-hoc CLI queries
coexist against the same file without lock contention. Don't reintroduce
read-write defaults. See tests/test_db.py::TestConcurrencyContract.
Viewer gotchas
viewer/README.md records the Three.js + R3F surprises we hit during
integration (sticky shader compilation, ShaderMaterial auto-prefix,
asymmetric USE_INSTANCING_COLOR chunk support, @react-three/test-renderer
shipping its own Three.js, Vite node_modules/.vite/ cache staleness). When
debugging visual bugs, check there before re-deriving from first principles.
Spec-locked TDD
SPEC.md is the source of truth for behavior. When adding a feature:
- Edit
SPEC.mdfirst if the behavior isn't already specified. - Write the test that pins the spec'd behavior.
- Implement until it passes.
If implementation reveals the spec is wrong, edit the spec — don't quietly diverge. A test failing because the spec changed is a better signal than a test that silently passes against a different contract.
Recurring primitives
A few patterns showed up in multiple unrelated places. Reach for them by name when designing.
Latest-value-wins, no history
Used by: FocusBroker (server), FocusController (client),
useFocusStream (SSE consumer), the URL/SSE merge in App.tsx, the
filter-chips selection set, the side-panel auto-open logic.
When events can arrive out-of-order or burst faster than they can be consumed, model the channel as a register, not a queue. Carry a monotonic timestamp on each event; consumers track "last applied" and drop anything stale. This makes most concurrency reasoning disappear — there is no FIFO to reorder, no backlog to bound. The cost is that you can never replay history; the benefit is that you never have to.
Avoid this pattern only when intermediate values are semantically required (a "playlist of stars to visit in sequence," for example). Those belong in a separate, additional channel.
Single source of truth per concept
The spectral colour LUT (colorForClass) is consumed by the per-instance
shader colour AND by the filter-chips legend bars; either could have
defined its own colours. Name resolution (resolve_name) has one Python
implementation, one TypeScript port, one shared JSON fixture. The focus
channel is the only path that updates camera, reticle, status badge, and
side panel — click, search, CLI, MCP, and URL all flow through it.
When you find yourself defining the same fact twice (a colour table, a ranking algorithm, a state mutator), pick the canonical home and have the other site read from it. The cross-language parity fixtures are the formal version of this discipline; most of the time it's just an import.
Verify before reasoning when state is in dispute
When a bug doesn't match your mental model, do not extend the chain of
deductions. Read the file, run the inspection, dump the actual value.
This project's worst debugging episodes (sticky shader compilation,
stale tsc -b emitted JS, multi-Three-instances test renderer)
resolved within minutes once we looked at the actual node_modules and
stack frames — and consumed hours when we didn't.
The rule of thumb: if you've made three guesses and none have explained the symptom, stop guessing and start instrumenting.
Errors travel with the request; broadcasts carry only success
Don't: fan failures across surfaces. CLI errors don't toast in the browser, server 404s don't replay over SSE, MCP tool failures don't trigger viewer-side notifications. Each surface (CLI, MCP, HTTP, in-viewer click) reports its own failures locally to its caller, in the form that surface natively uses (exit code + stderr, structured payload, HTTP status, toast).
Do: programmatic surfaces validate synchronously and return errors immediately. Multi-step actions validate the whole thing upfront and return one structured result; never "accept and discover errors during execution." If we ever build a "grand tour" feature — fly the camera through a sequence of stars — the endpoint must resolve every star at submission time and return 400 if any are unknown, not stream partial success and failure events interleaved through SSE.
The broadcast channels we have (the focus SSE stream, the toast bus) carry committed state only. By construction, anything that arrives on them has already been validated by some surface; subscribers can act on it unconditionally.
Why: double-reports (terminal AND toast for the same CLI error) are user-hostile, and lazy validation defers feedback past the point where the user can correct the input without losing context. The simple mental model — "the surface that asked, learns the result" — is substantially easier to reason about than any cross-surface error routing scheme.
Naming evolves
When the abstraction shifts, rename. SolMarker became FocusReticle
the moment the ring's role changed from "this is Sol forever" to "this
is your current selection"; useStars's payload field went from stars
to data when other hooks needed the same shape. The cost of renaming
in a typechecked codebase is small; the cost of a name that mis-describes
its role accumulates with every new reader.
Aesthetic-architectural alignment
More than once on this project, doing the right thing structurally also produced an unexpected aesthetic dividend:
- The custom
ShaderMaterialwas forced on us by Three's chunk-system asymmetry, but ended up giving direct access to fragment-shader hacks for limb darkening. colorForClasslived in its own module to be parity-locked across Python and TypeScript; that made the filter-chip legend a one-line consumer.- Latest-value-wins was the simplest broker model; it turned the URL/SSE/click/CLI focus-merge into a non-problem.
- Routing every UI state change through the focus channel was a
distributed-correctness discipline; it's why the agent's MCP
set_focustool can surface star context to a human and the human's clicks surface to the agent without any extra plumbing.
This isn't luck — it's a sign that the abstraction is honest. When code and UX point the same direction, the design is communicating something true about the domain. Recognize it in the moment; later it's easy to mis-attribute the result to taste.