Each of the day's top significant transits now gets a short sentence describing what it classically means and which area of life its house governs, condensed from Sepharial's Transits and Planetary Periods (1920, public domain) plus a small table of traditional house significations. The day-level line also shows its rank out of the 4 defined levels, e.g. "Notable (2/4)". Docs (CLAUDE.md, README, docs/input-output-format.md, docs/reading.md) updated to match, including a full worked example in docs/reading.md.
18 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project goal
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
Cross) and astrology (natal chart + transits) reading. Currently only the
core engine exists: a plain-C, dependency-free library plus a
standalone CLI binary (dist/deck-engine) that can be built and run
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI,
resource packs) has not been started yet — the engine is deliberately
built so it can be linked straight into it later without rework (see
"Portability to the watch" below).
Build & run
make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/,
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
A single Makefile at the repo root builds both engine/ and
interpreter/ — there is no per-module Makefile, and no cd needed
before running make. Build output goes to dist/, a sibling of
engine/, interpreter/, and res/. There is no make install;
dist/ is meant to be run in place.
Two ways to run a reading:
# 1. Direct CLI, all inputs explicit:
dist/deck-engine --seed "2026-07-03" \
--birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
--birth-lat 52.5200 --birth-lon 13.4050 \
[--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] [--i18n-dir <path>]
# 2. dist/run-engine.sh [text|html|json] [lang] — wraps the binary for
# daily use: seed and --date come from the OS clock (today's date /
# current UTC time), birth data + default language are read from
# dist/user.properties next to the script; the optional [lang] arg
# overrides that language for a single run.
dist/user.properties is seeded once from engine/scripts/user.properties.template
with placeholder values (YOUR_BIRTH_DATE_HERE, etc.) — run-engine.sh/
run-interpreter.sh refuse to run (with a clear error) until the
placeholders are replaced with real values. Because dist/ itself is
disposable (make clean, or just deleting the directory, wipes it), the
scripts Makefile target also keeps a durable backup outside dist/:
once dist/user.properties looks filled in (no leftover YOUR_...), every
build copies it out to engine/scripts/user.properties.local
(gitignored); if dist/user.properties is ever missing when a build
runs, it's restored from that backup instead of being reseeded from the
placeholder template. dist/run-interpreter.sh is the equivalent
daily-use wrapper for the interpreter: it runs run-engine.sh's same
OS-clock seed/date and user.properties birth data through deck-engine --format json, piped straight into dist/interpreter-cli — see
"Interpretation" below.
Run a single smoke test by editing engine/tests/smoke_test.c's main()
temporarily, or just read its assertions — there's no test filter flag,
the whole suite runs in milliseconds.
Architecture
deck_in_a_dash/
res/ Tarot card art (22 RWS Major Arcana JPEGs),
Waite's "Pictorial Key to the Tarot" (PDF,
public domain — source of all card/spread
text), Sepharial's "Transits and Planetary
Periods" (PDF, public domain — source of
interpreter/src/narrative.c's text), logo,
research notes.
engine/
third_party/astronomy/ Vendored cosinekitty/astronomy C library
(MIT, pinned commit — see VENDORED.md).
Unmodified from upstream.
src/
rng.h/.c Deterministic string-seeded PRNG.
tarot.h/.c Celtic Cross spread draw logic.
tarot_data.c 22 cards' names/meanings + 10 position
names/descriptions (Waite's own text).
astro.h/.c Natal chart + daily transits.
i18n.h/.c key=value translation catalog + English fallback.
reading.h/.c Ties tarot + astro together; text/HTML output.
main.c CLI entry point.
i18n/en.lang, i18n/de.lang Translation source files, one per language.
tests/smoke_test.c
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
interpreter/ Separate module + binary; see "Interpretation" below.
dist/ Build output (gitignored-style; see Build & run).
Makefile Single root Makefile, builds both engine/ and interpreter/.
Data flow / the real API
reading_generate(seed, birth, utc_moment, &DailyReading) in
reading.h is the single entry point that matters — it's what the
watchapp will call once it exists. It populates a plain struct
(NatalChart + DailyTransits + CelticCrossSpread) that the caller
walks directly to build a UI. reading_print_text/reading_print_html
are desktop-only conveniences for inspecting a reading (used by the CLI);
the watchapp will never call them.
Determinism (rng.c)
The Celtic Cross draw must be exactly reproducible from a seed string, on
any platform (including the watch's ARM core later), so rng.c is pure
integer arithmetic: FNV-1a hashes the seed string, splitmix64 streams
from it, and a partial Fisher–Yates shuffle (tarot.c) draws the 10
cards plus a reversal bit per card from that same stream, in one pass.
Known-sharp-edge: rng_next_bounded's rejection-sampling limit must
stay a uint64_t. When bound evenly divides 2^32 (true for bound == 2, used by every reversed-card coin flip), the true rejection threshold
is 2^32, which silently truncates to 0 in a uint32_t and turns the
loop into an infinite one. This exact bug shipped once already — if
you touch this function, keep the intermediate type 64-bit.
Astrology (astro.c)
- Bodies: Sun, Moon, Mercury–Pluto (
Bodyenum inastro.h, prefixedPLANET_*— deliberately notBODY_*, because Astronomy Engine's ownastro_body_talready definesBODY_SUN,BODY_MOON, etc., and both headers are included together inastro.c). - Positions must go through
Astronomy_GeoVector+Astronomy_Ecliptic, notAstronomy_EclipticLongitude. The latter computes heliocentric longitude and outright rejectsBODY_SUN(ASTRO_INVALID_BODY) — wrong for astrology, which needs apparent geocentric position. This also shipped once and was caught by the natal-Sun-sign smoke test. - Houses use the Whole Sign system (house 1 = the Ascendant's sign,
houses follow zodiacally) — no Placidus/Koch cusp trig. Astronomy
Engine has no Ascendant function; it's computed directly in
astro.cfrom sidereal time + a standard low-precision obliquity polynomial + the RAMC/obliquity/latitude identity (Duffett-Smith & Zwart). - Every
PlanetPosition(astro.h) carries a whole-signhouse(1-12), viaassign_houses()/whole_sign_house().DailyTransits.bodies[*].houseis always relative to the natal Ascendant, not a fresh "houses for right now" chart — that's the standard, personalized way transits are read (e.g. "transiting Jupiter is in your 5th house"), and it's whyastro_compute_daily_transitstakes the natal chart as a parameter in the first place (also used for aspect detection). - Aspects (conjunction/sextile/square/trine/opposition) use an 8° orb when a luminary (Sun/Moon) is involved, 6° otherwise.
Tarot (tarot.c, tarot_data.c)
Only the 22 Major Arcana are modelled (TAROT_DECK_SIZE) — matches the
art actually in res/img/. The 10 Celtic Cross positions
(CelticCrossPosition in tarot.h) follow Waite's own original
drawing order from The Pictorial Key to the Tarot, Part III §7 ("An
Ancient Celtic Method of Divination"): Present → Challenge → Crown →
Foundation → Recent Past → Near Future → Attitude → Environment →
Hopes/Fears → Outcome. Note this differs from the reordering used by
most modern tutorials (which typically put Foundation/Recent Past before
Crown) — res/2026_07_03_celtic_cross_spread.txt has an example of that
more common (but less original) ordering; don't use it as a reference for
the position enum order. Card and position text in tarot_data.c is
condensed from Waite's own wording in Part III §3 (public domain).
Translations (i18n.c, engine/i18n/*.lang)
All display text (planet/sign/moon-phase/aspect names, tarot card names
and meanings, Celtic Cross position names, and reading.c's section
headers) is looked up in a small process-global catalog loaded by
i18n_load(path) from a key=value file, via i18n_get(key, fallback).
Every call site passes the hardcoded English string as fallback, so a
missing file, a missing key, or an untranslated language degrades to
English rather than showing a raw key or nothing — this is what lets
engine/i18n/de.lang (or any future language) be filled in incrementally.
Keys are namespaced by the slug tables in tarot_data.c/astro.c
(card.<slug>.name, card.<slug>.upright/reversed,
position.<slug>.name/desc, body.<slug>, sign.<slug>,
moonphase.<slug>, aspect.<slug>), plus ui.* keys for reading.c's
own labels — deliberately keyed off separate slug strings rather than
the C enum names, so renaming an enumerator can never silently break a
.lang file. To add a language: copy engine/i18n/en.lang to
<code>.lang in the same directory (keys must match exactly) and
translate the values — no source changes needed, make picks up any
*.lang file there automatically.
i18n_load uses stdio (fopen/fgets), so — like main.c and
reading_print_* — it's a desktop-only entry point; i18n_get itself
is a pure fixed-size-array lookup with no heap allocation, so it's fine
to port to the watch once it has its own (non-file-based) way to
populate the catalog, e.g. from a compiled-in resource.
Portability to the watch
rng, tarot, tarot_data, astro, i18n_get, and reading_generate
are kept free of stdio/CLI assumptions specifically so they can link
into the Pebble watchapp unchanged later. Only reading_print_text/
_html (text formatting), main.c (CLI arg parsing), and i18n_load
(reads a file from disk) are desktop-only and won't port as-is.
Interpretation (interpreter/)
A second, separate top-level module and its own binary
(dist/interpreter-cli, sibling to dist/deck-engine) that scores how
significant a day's transits are and, for each of the top items, prints
a short narrative sentence about what that transit classically means —
the first piece of a larger "turn the raw reading into an actual
narrative" effort described in project chat history; the Celtic Cross
spread itself isn't woven into that narrative yet (see
docs/reading.md). deck-engine itself is deliberately unchanged by
this beyond gaining --format json; the two binaries compose over that
JSON, never by linking together:
make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c
dist/deck-engine ... --format json | dist/interpreter-cli
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
# or, for daily use (OS clock + user.properties, same as run-engine.sh):
dist/run-interpreter.sh
The root Makefile builds both from one invocation, but keeps them as
separate compilation units throughout — no object file is ever shared
between the two binaries (see the next bullet).
interpret_daily_reading(reading, &out)(significance.h/.c) scores every aspect inreading->transits.aspects[], keeps the top 5 by score inDailyInterpretation.top_items[](descending, evicting the rest), and classifies the day into aDaySignificanceenum (DAY_QUIET...DAY_MAJOR) from the single highest-scoring item. The enum also carries a trailingDAY_SIGNIFICANCE_COUNTsentinel (not a real level) purely so callers can print the level as a rank, e.g.main.c's"%s (%d/%d)"→"Notable (2/4)"—interp.day_level + 1out ofDAY_SIGNIFICANCE_COUNT.interpretation_deserves_framing()is true only atDAY_MAJOR— the intended hook for giving the Celtic Cross reading a "this matters" intro sentence namingtop_items[0]on days that earn it; that rendering doesn't exist yet,main.c's report is plain text only.- Score =
transiting-planet weight (slow/outer planets score higher) × orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus (transits to natal Sun/Moon score higher than to other planets). The weights and theDAY_QUIET/DAY_NOTABLE/DAY_SIGNIFICANT/DAY_MAJORthresholds insignificance.care hand-tuned, not derived from anything physical — expect to retune them once real readings are compared against how "major" a day actually feels. narrative.c/narrative.hsuppliesmain.c's one-sentence description printed under each top item, condensed from Sepharial's Transits and Planetary Periods (1920, public domain —res/Transits_and_Planetary_Periods.pdf), Chapter VIII "Effects of Transits". Per transiting planet it holds abasedescription plus aharmonious/discordantaddendum, shown only under a trine/sextile or square/opposition respectively (either may be""if the source gives none); a conjunction is treated as neutral — base only — since Sepharial's own text says a conjunction "takes the nature of what it conjoins," which isn't modelled here. The Moon and Pluto aren't in that chapter (fast lunar transits were out of scope for a year-ahead forecasting book; Pluto wasn't discovered until 1930) — their text is original, written for this project, not drawn from Sepharial. Each narrative is also framed by where it's happening:narrative_print()takes the transiting planet's whole-sign house (1-12, relative to the natal chart, same as everywhere else in this codebase) and prefixes an "In matters of ..." line naming that house's traditional area of life, from a smallk_house_area[12]table of standard whole-sign house significations — centuries-old convention, not attributed to any single source (same status as the sign/aspect names already inastro.c). Passing a house outside 1-12 (the same "no data" sentinelreading_io.cuses elsewhere) omits that framing and prints the planet's narrative on its own.reading_print_json(engine/src/reading.c) is the wire format: the fullDailyReading(natal, transits, spread), using the language-independentastro_*_slug()/tarot_*_slug()accessors (astro.h/tarot.h) rather thanastro_*_name()/tarot_*_name()— the JSON must stay identical regardless of--lang, since it's a machine interchange format, not display text.json.c/json.his a small hand-rolled recursive-descent JSON parser (object/array/string/number/bool/null) - not a general-purpose validating library, just enough to parsedeck-engine's own output. Unlike the core engine, it usesmalloc;interpreter-cliwas never meant to run on the watch itself.reading_io.cwalks the parsed JSON down to"transits"."aspects"and fills aDailyReadingwith those (used for scoring), plus"natal"."bodies"/"transits"."bodies"(each body's sign + whole-sign house, viaparse_bodies()— used only for the sign/house contextmain.cprints alongside each significant event, never for scoring)."spread"is left zeroed entirely - nothing reads it. An aspect naming a planet/aspect-type slug it doesn't recognize, or a document missing"transits"."aspects"entirely (as opposed to a present-but-empty array, which is a valid "no aspects today"), makes the whole load fail; by contrast an unrecognized body slug or missinghouseinside"bodies"is silently skipped (that body'shousestays0, an invalid whole-sign house number used as "no data" -main.cchecks for it before printing sign/house), since that's supplementary display context rather than something scoring depends on.- Deliberately header-only dependency on the engine, throughout:
significance.h/reading_io.h/narrative.h#includeengine headers (reading.h/astro.h) for the struct/enum definitions, but the rootMakefilenever compiles or links any engine.cfile (or the vendored Astronomy Engine) into the interpreter's binary or tests — every test ininterpreter/tests/runs against hand-built JSON text or hand-builtDailyReadingvalues, with no real ephemeris/tarot-draw call involved anywhere. Consequentlyreading_io.c(planet/aspect/sign slugs) andmain.c(planet/aspect/sign display names) each duplicate a small local table rather than linkingastro.cto reuse its own - keep those in sync if the engine's slugs/names ever change. - Only
Aspects compete for the top 5 so far (SignificantItemKindis currently justITEM_ASPECT); moon phase and house ingresses are candidate future item kinds but aren't scored yet.
Licensing
- Engine code (
engine/src/,engine/scripts/) and the rootMakefile: MIT, per the repo's top-levelLICENSE. engine/third_party/astronomy/: vendored MIT code, unmodified — seeVENDORED.mdfor the pinned upstream commit.- Card/spread text in
tarot_data.c/engine/i18n/en.lang: condensed from A. E. Waite's The Pictorial Key to the Tarot (1911), public domain. engine/i18n/de.lang's card/spread text is an original translation of that same public-domain English text, made for this project — not taken from any specific published German edition.- Transit narrative text in
interpreter/src/narrative.c: condensed from Sepharial's Transits and Planetary Periods (1920, public domain —res/Transits_and_Planetary_Periods.pdf), except the Moon and Pluto entries, which are original (see narrative.c's own comment for why).