Files
History/CHANGELOG.md
T
kamilkrzywda f1cc4fbc10
CI / build + unit + e2e (pull_request) Successful in 2m18s
Smooth jagged sub-national region boundaries (v0.16.0)
Part of #16
2026-10-13 09:42:44 +00:00

27 KiB
Raw Permalink Blame History

Changelog

All notable changes to History are listed here, newest first.

v0.16.0 — 2026-10-13 09:37

  • Smoothed the jagged sub-national region boundaries with Douglas–Peucker vertex-thinning (Part of #16). The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization left some boundaries overly jagged — the worst being regions the dissolve split into many small zig-zag fragments, e.g. SI-048 Kocevje (Slovenia, 57 parts) and MK-36 Kavadartsi (N. Macedonia, 8 parts). The new scripts/smooth-regions.mjs (npm run smooth:regions) runs @turf/simplify (Ramer–Douglas–Peucker, tolerance ~50 m) over every ring of every polygon, dropping the redundant "in-between" vertices of a shallow zig-zag while keeping the overall shape.
  • Vertex-thinning only — no topology change. DP only removes points: it never moves a border, never merges/dissolves a part, and never creates or removes a ring. So the dataset contract is fully preserved — still 1,796 regions with identical id / properties.name / properties.parent, all 177 countries covered, the same 5,026 parts / 5,027 rings, the single KZ-KZY hole, and all 83 islands. The 18 degenerate 3-point "micro-dot" rings (Ragged Island, the Paracels, …) are kept verbatim (@turf/simplify requires ≥4 pts and would otherwise throw).
  • A gentle pass, reported honestly. At 50 m the dataset is already fairly smooth (the v0.14 dissolve produced low-vertex, widely-spaced boundaries — median consecutive-vertex spacing ~18 km), so this is a light cleanup: 111,891 → 111,175 vertices (716 removed, 0.64%) across 542 of 1,796 regions. Kocelje 612 → 599 and Kavadartsi 169 → 163 verts; both keep their exact part count (the "mess" there is fragmentation, which thinning — under the no-topology-change constraint — does not fix). The file shrinks 2,302,473 → 2,289,528 bytes (2.29 MB). Bump TOLERANCE_M to 100 / 200 m and re-run for a stronger pass (100 m → 974 removed; 200 m → 1,312 removed) — the pass stays idempotent either way.
  • The "messy" regions are really fragmentation artifacts, not fine jaggedness. Ranking by part density (parts packed per unit area) rather than raw part count — which is dominated by legitimate dispersed archipelagos (Nunavut 213, Alaska 159, Antarctica 135, British Columbia 84) — surfaces the genuinely messy coherent regions the dissolve left fragmented: SI-048 Kocevje (SVN, 57 parts in ~16,600 km²), GR-H Stereá Elláda / Central Greece (GRC, 53 parts), KR-46 South Jeolla (KOR, 28 parts), SE-C Uppsala (SWE, 19 parts), HR-13 Zadarska (HRV, 17 parts), plus the milder Balkan MK-36 Kavadartsi (MKD, 8 parts). These real land regions (the Balkan ones in particular) ended up as many scattered fragments — the "similar shape problems".
  • Deterministic + idempotent. One @turf/simplify pass at a fixed tolerance, then 4-dp rounding (the same convention as the other scripts). The output already sits at the pass's fixed point, so re-running on the smoothed file is byte-identical (sha256 70a43541…, verified across repeated runs); public/regions.geo.json and the built dist/regions.geo.json are sha256-identical.
  • Tests. scripts/regions.test.ts gains an eighth test pinning the v0.16 fixed point: 1,796 regions, 5,026 parts, 5,027 rings, the single KZ-KZY hole, and exactly 111,175 vertices (8 tests total). All prior assertions (well-formed, unique ids, 177-country coverage, area floor, holes, coverage band) pass unchanged on the smoothed dataset.
  • Part of #16

v0.15.0 — 2026-10-12 13:49

  • Filled the small holes left by the v0.14 coarsening (Part of #16). The coarsened public/regions.geo.json still carried 2,245 tiny enclosed holes (all < 500 km² in practice) — unfilled patches left by the boolean dissolves and Natural Earth's own digitization, which showed up as "holes in regions". The new scripts/cleanup-regions.mjs (npm run cleanup:regions) drops every inner ring smaller than the 1,000 km² threshold so each region becomes solid: across 498 features, 56,484 km² of artifact area was filled, removing the visible unfilled dots.
  • Real water is kept, not erased. Any enclosed hole ≥ 1,000 km² survives as a hole. In this dataset the genuinely large lakes (the Great Lakes, the Caspian, the Aral, Lake Superior) had already been dissolved by the v0.14 coarsening, so none remain to preserve; the only surviving hole is a single medium one — KZ-KZY (Kyzylorda, Kazakhstan) at 5,858 km², a real water body in the Aral basin — which is kept. The 1,000 km² threshold sits comfortably between the tiny artifacts (all < 500) and any genuine lake, so it fills exactly the artifacts and nothing else.
  • The dataset contract is untouched. Still 1,796 regions with identical ids / properties.name / properties.parent (0 mismatches), all 177 countries still covered, the ~100 km floor and the island exemption intact — only inner rings were dropped, never a feature or its identity. The file shrinks 2,431 → 2,249 KB (2.20 MB) (fewer inner rings).
  • Deterministic + idempotent. Fixed threshold, deterministic planar geometry, 4-dp rounding; re-running on the cleaned file rewrites a byte-identical file (sha256-verified). Bridging very-close MultiPolygon parts is available behind an opt-in --bridge flag but is off by default, because the union it performs can itself mint new medium-sized holes — counter to the fill-holes goal — so the shipped run is the clean hole-fill only.
  • Tests. scripts/regions.test.ts gains a seventh test asserting that no enclosed hole below the cleanup threshold remains and that only a handful of real water bodies survive (57 tests total). public/regions.geo.json and the built dist/regions.geo.json are sha256-identical.
  • Part of #16.

v0.14.0 — 2026-10-12 09:51

  • Coarsened the sub-national choropleth dataset to a ~100 km floor (Part of #16). public/regions.geo.json shrinks from 4,138 real administrative regions to 1,796 (2,342 merges): every region smaller than ~100×100 km (10,000 km²) that has a same-country neighbor within 0.2° (≈22 km) of its bounding box is dissolved into its nearest such neighbor (nearest by centroid distance; ties → larger area, then id). Standalone cities and micro-districts collapse into the region around them: US-DC (191 km²) into Maryland, FR-75 Paris (59 km²) into Hauts-de-Seine, the UK's 232 admin-1 districts down to 16 regions, and all of North Macedonia's 85 municipalities into one whole-country region.
  • Islands are exempt. A region with no same-country neighbor within 0.2° keeps its shape whatever its size — 83 such small islands survive in the final set (Corsica, the Kerguelen/Crozet archipelagos, the Bahamas districts, Lord Howe, …) and the coarsening's fixed point leaves 0 non-island regions below the floor.
  • Real dissolve, not overlap. Each merge is a real boolean union (@turf/union, polygon clipping) that removes the shared boundary between the merged region and its host, yielding one clean polygon — the final dataset has 0 fallback (non-dissolved) unions. Passes iterate smallest-area-first to a fixed point (2 passes on the v0.13 data, ~7 s); the output is sorted by (parent, id) at 4-dp precision and is byte-reproducible: re-running on the coarsened file reaches the fixed point immediately and rewrites an identical file (sha256-verified).
  • Numbers. Per-country region count: min 1 / median 7 / max 80 (Russia; was 1 / 15 / 232 — the UK). Region areas: median 23,933 km²; smallest ≈0 (degenerate micro-dots like Ragged Island and the Paracel Islands, kept as islands); largest is Antarctica itself (≈21.8M km² in the planar model, exempt — it wraps a full 360°). 158 of the 177 countries had at least one merge. The file is 2,431 KB (2.37 MB) — under the 5 MB guard, so no Douglas–Peucker pass was needed.
  • Tooling + tests. New scripts/coarsen-regions.mjs (npm run coarsen:regions) performs the merge in place; scripts/regions.test.ts gains a sixth test asserting that below-floor non-island regions stay under 1% of the dataset (currently 0) alongside the existing well-formed / unique-id / 177-country coverage / area / coverage-band checks (56 tests total). The map's data contract is unchanged (properties.parent → RegionMap), so WorldMap.tsx, App.tsx, shared/protocol.ts, and server/map.ts all keep working as-is.
  • Part of #16.

v0.13.0 — 2026-10-12 08:30

  • Replaced the geometric sub-region grid with real named sub-national administrative regions (#16, PR 2). The choropleth source public/regions.geo.json now contains real named administrative regions — states, provinces, governorates, oblasts, … (Natural Earth admin-1, 1:10m) — instead of the abstract ~100–200 km geometric grid. Each region keeps a stable id and a name. The map's data contract is unchanged (properties.parent → the parent country's ISO_A3, properties.name → the region name), so no change to the map code or the LLM contract: WorldMap.tsx, App.tsx, shared/protocol.ts, and server/map.ts all keep working as-is.
  • Generator rewritten — scripts/generate-regions.mjs (same path, dependency-free, npm run generate:regions) now groups the admin-1 features by their adm0_a3 (the parent's ISO_A3), keeps only parents in the 177-country set, and id's each region with its ISO 3166-2 code when present (e.g. US-CA, FR-75, RU-MOW) else <ISO_A3>-<nn>; within a country, ids are disambiguated with a -2/-3/… suffix on collision (ISO 3166-2 codes are not always unique within a country — e.g. all nine of Bosnia's entities report BA-BIH). A country with no admin-1 data would collapse to one whole-country region <ISO_A3>-01 (0 are needed at 1:10m, which covers all 177). Coordinates are rounded to 4 decimal places, and the file is Douglas–Peucker-simplified only if it exceeds the ~8 MB guard. Output is sorted by (parent, id) and fully deterministic.
  • The dataset is 4,138 regions covering all 177 countries, 2,831 KB (2.76 MB) after Douglas–Peucker (the 4-dp base is ~23 MB). Per-country region count: min 1 / median 15 / max 232 (United Kingdom). There are 0 whole-country fallbacks — 1:10m admin-1 covers every one of the 177 (the five single-region countries are SOL, SAH, CYN, PRI, FLK). Samples: US-CA California and US-NY New York (US); FR-75 Paris and FR-13 Bouches-du-Rhône (France); RU-MOW Moskovskaya (Russia).
  • Validation test updated — scripts/regions.test.ts was rewritten for the new dataset: well-formed features, globally unique ids, all 177 countries covered (≥1 region each), sane region areas (median ≥ 100 km², no non-Antarctica region ≥ 5,000,000 km² — Antarctica is exempt because its full-360° wrap ring breaks the planar area model), and each country's region area summing to within [40%, 175%] of its own area (median within [70%, 130%] — the band is wider than a naive [40%, 110%] because Natural Earth's admin-1 polygons are digitized independently of the admin-0 country polygons and are not clipped to them). Runs in O(total vertices).
  • Part of #16.

v0.12.0 — 2026-10-12 01:03

  • Map now renders the sub-national regional dataset (#16, PR 2). The choropleth source switches from public/countries.geo.json (177 countries) to public/regions.geo.json (2,597 sub-regions): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed countries/countries-fill/countries-line → regions/regions-fill/regions-line, and the border line is thinned to 0.75 (from 1.25) so the subdivision reads within each country.
  • Ownership stays at country granularity. A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's properties.parent (the parent country's ISO_A3) — never the sub-region id (FRA-03) nor the repacked top-level feature.id. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click.
  • New shared helper mapFeatureOwner(regions, feature) in @shared/protocol — the single source of truth for resolving a rendered map feature's owner via properties.parent → RegionMap[parent] (built on the existing regionOwner key lookup). The territory-change flash now expands a changed country to all of its sub-regions (via the new featuresByParents), so a whole-country transfer still pulses correctly at the finer resolution.
  • No ownership-model or LLM change — buildRegionMap/RegionMap remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3.
  • Tests — new mapFeatureOwner unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric id, regionId = sub-region code, parent = ISO_A3) and proving resolution via parent while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry.
  • Part of #16.

v0.11.0 — 2026-10-12 00:31

  • Sub-national region dataset + generator (#16, foundation only — no app behavior change). A new dependency-free scripts/generate-regions.mjs (run via npm run generate:regions) splits each of the 177 countries in public/countries.geo.json into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed public/regions.geo.json (2,597 regions, ~755 KB). Each region is id'd <ISO_A3>-<nn> (nn = 1..k in row-major order, capped at 36 per country) with properties = { name, parent }, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
  • Validation test — new self-contained scripts/regions.test.ts (picked up by npm test) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches ^[A-Z]{3}-\d+$ and whose parent is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
  • Fixes #16.

v0.10.0 — 2026-10-11 21:25

  • Fix world map style (#2) — made the choropleth fill fully opaque (fill-opacity 0.85 → 1.0) and darkened the Natural Earth terrain raster (raster-opacity 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
  • Strengthened country border lines — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
  • Fixes #2.

v0.9.0 — 2026-10-11 20:57

  • Reduced turn latency (prompt-level): capped recentEvents in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (reasoning_effort: low); the deterministic stub, the TurnResult contract, max_tokens, and reasoning_effort are all unchanged.

v0.8.0 — 2026-10-11 20:29

  • Remove a saved game (#20) — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new DELETE /api/games/:id route removes the game's row from SQLite and returns { deleted: true } (404 if the id is unknown).
  • Redirect on a missing/deleted game (#20) — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears while its tab is open (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE error event on delete, and the client's useSse gains an onError handler that returns the tab to the home screen (matching the existing "game not found" connect behavior).
  • PresetPicker gained an onDelete prop, a per-card data-game-id attribute, the delete button, and the confirm dialog; api.deleteGame(id), Db.delete(id), and the shared DeleteGameResponse type were added to support it.
  • Unit + e2e tests — new Db.delete test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is {deleted:true}, and a follow-up GET is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home).

v0.7.0 — 2026-10-11 19:17

  • Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
  • Fixed: a clicked owned region no longer reports "no owner" (#6). Root cause: MapLibre repacks a GeoJSON source's top-level id (the ISO_A3 string) into a numeric index when it stores the source, so the feature a click/hover handler receives has a number for id — the old regions[f.id] lookup resolved to nothing, and the popup read "No owner" even for owned regions (the choropleth fill still worked because it is stamped from the original id before setData). Fix: applyRegions now stamps the stable ISO_A3 code into properties.regionId (and ownerId), which survives into the rendered features, and both the click and hover handlers resolve the owning nation from that code via the authoritative RegionMap — never from the repacked feature.id.
  • Unowned regions (no entry in the RegionMap) do not change the dossier when clicked — the current target is left in place (a deliberate, non-crashing choice rather than a special "unowned" dossier state).
  • New shared helper regionOwner(regions, isoA3) in @shared/protocol (the pure, single source of truth for "ISO_A3 → owner record").
  • Unit tests — new regionOwner tests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numeric id, ISO_A3 in properties.regionId) and asserts resolution via properties.regionId works while the old feature.id path fails (44 tests, up from 39).

v0.6.0 — 2026-10-11 18:27

  • Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.

v0.5.0 — 2026-10-11 17:36

  • HUD revamp — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead MapApi/onMapReady map plumbing is removed from WorldMap); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new pinned mode on FloatingWindow — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new dock/onDockChange mode on FloatingWindow, with small left/right/float controls in each title bar, choice persisted in localStorage and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
  • FloatingWindow — new optional props: pinned (top-center fixed bar), dock ('left'/'right' full-height columns) and onDockChange (renders the dock controls and reports the mode), plus dataTestId (a data-testid on the window root for e2e). anchor is now optional (defaults to top-center).
  • New data-testids — top-bar (pinned header), panel-dossier / panel-feed / panel-action (per-window roots), and dock-left / dock-right / dock-float (per-window dock controls).
  • New localStorage key — history.floatwin.v1.dock (per-id dock mode); mirrors the existing history.floatwin.v1.collapsed.
  • E2E — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under LLM_STUB=1 (11 tests, up from 9).

v0.4.0 — 2026-10-11 16:02

  • Fix: queued items disappearing from the pending list (#10). Root cause: SSE state events carried no freshness information, and the client's onState/onTurn handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive after the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic GameState.version (persisted in SQLite, started at 1 on create, bumped by queueRoundItem, the new removeRoundItem, and applyTurnResult; legacy games without the field are treated as version 0), and the client routes every state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional setGame updater, so it is stale-closure-proof).
  • New: remove button for queued items (#11). Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn Dialog confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new POST /api/games/:id/queue/remove (body { index }) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client api.removeQueueItem(id, index) is version-gated like every other state update, so the list can never regress after a removal.
  • Unit tests — new removeRoundItem tests (valid removal + version bump, out-of-bounds/non-integer → null), version-bump assertions for queueRoundItem/applyTurnResult/stub turn, a legacy-no-version case, and version round-trip in db.test.ts (39 tests, up from 35).
  • E2E — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under LLM_STUB=1.

v0.3.0 — 2026-10-11 15:29

  • Deep-linking — the active game's id now lives in the URL (#/game/<id>), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.

v0.2.0 — 2026-10-11 14:24

  • Batched round model — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in game.pending (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
  • New routes — POST /api/games/:id/action and POST /api/games/:id/diplomacy (fast, no LLM); POST /api/games/:id/jump (the single LLM call); POST /api/games/:id/turn removed.
  • New client API functions — api.addAction(id, text), api.addDiplomacy(id, to, message), api.jump(id); api.turn(...) removed.
  • ActionPanel rewrite — Action tab: textarea + small "Add action" button. Diplomacy tab: target select + textarea + small "Add to round" button. Advisor tab: input + "Ask advisor" button. Below the tabs: a visible pending-queue list (⚡ for action, 💬 for diplomacy with target name, truncated text, count badge) and a large "Jump forward" primary button that shows the pending count and disables while a jump is in flight.
  • App.tsx — runAddAction / runAddDiplomacy handlers (no busy state); runJump (with busy); the "Your move" window's title bar now shows a pending-count badge chip instead of the old jump button.
  • Deterministic stub updated — deterministicTurn now emits one event per queued item (action → military/success, diplomacy → diplomatic/partial) instead of one event per turn; still sets player military to clamp(cur+5) and allies with the first other nation.
  • Unit tests — rewrote engine.test.ts with batch-aware tests (7 new tests, 35 total up from 28); updated db.test.ts and map.test.ts fixtures to include pending: [].
  • E2E — rewrote e2e/sim.spec.ts to the new flow: queue action → assert pending list → queue diplomacy → assert pending list → jump → assert turn+1, date+2, military 80→85, two events, pending cleared, ally relation.
  • AGENTS.md — added Gitea-issue step to the change process; updated Layout and Conventions to reflect the batched-round model.

v0.1.0 — 2026-10-11 08:02

  • Added a Gitea Actions CI pipeline (.gitea/workflows/ci.yml: typecheck → lint → unit → build → deterministic Playwright e2e). E2e runs against the app's own dev webServer (LLM_STUB=1) — no dind/docker.
  • Committed package-lock.json so CI uses a reproducible npm ci.
  • Switched to the mandatory PR-based workflow: feature branch → PR to master → CI green → auto-merge → tag + manual deploy (documented in AGENTS.md).
  • Versioned the repo at 0.1.0 (package.json) and added CHANGELOG.md, .env (gitignored) and .env.example.
  • This change is the first one landed through the new PR process (dogfooding it).