diff --git a/plans/260422-2128-semantle-module/phase-01-foundation.md b/plans/260422-2128-semantle-module/phase-01-foundation.md deleted file mode 100644 index 6bfcb75..0000000 --- a/plans/260422-2128-semantle-module/phase-01-foundation.md +++ /dev/null @@ -1,115 +0,0 @@ -# Phase 1 — Foundation - -Module scaffold, KV state, word2sim HTTP client, env wiring. -Nothing playable at the end of this phase, but all glue is in place. - -## Context links - -- Overview: `./plan.md` -- Existing parallel module: `src/modules/loldle/` (follow shape) -- External API contract: `tiennm99/word2sim` repo `README.md` - - `GET /random?min_rank&max_rank&alpha_only&min_len&max_len` → `{word, rank}` - - `GET /similarity?a&b` → `{a, b, canonical_a, canonical_b, in_vocab_a, in_vocab_b, similarity}` - -## Files to create - -### `src/modules/semantle/index.js` (~45 LOC) -Module export. Mirrors `src/modules/loldle/index.js`: -- Captures `{db, env}` in `init({db, env})` — `env` is new vs loldle (need `WORD2SIM_API_URL`). -- Exposes 5 commands (see `plan.md`). Each handler closure gets `(ctx, { db, apiBase })`. - -### `src/modules/semantle/api-client.js` (~80 LOC) -Thin wrapper over word2sim HTTP endpoints. Example shape: -```js -export function createClient(apiBase) { - return { - randomWord: (opts) => fetchJson(`${apiBase}/random`, opts), - similarity: (a, b) => fetchJson(`${apiBase}/similarity`, { a, b }), - }; -} -``` -- Normalize `apiBase` to strip trailing slash. -- Build query strings with `URLSearchParams`. -- Timeout via `AbortController` (5s). -- Throw `Word2SimError` with `{status, body}` on non-2xx; caller decides user-facing message. -- `User-Agent: miti99bot/semantle` header for traceability. - -### `src/modules/semantle/state.js` (~100 LOC) -KV persistence. Key layout under `semantle:` prefix: -- `game:` → `{target, startedAt, solved, guesses:[{word, canonical, similarity}]}` - — `target` stored lowercased; solve = `canonical.toLowerCase() === target`. -- `stats:` → `{played, solved, totalGuesses, bestGuessCount, lastResultAt}` - -Exports: -- `loadGame(db, subject) → GameState | null` -- `saveGame(db, subject, state)` — TTL `60*60*24*7` (7d) -- `clearGame(db, subject)` -- `loadStats(db, subject) → Stats` (returns defaults if missing) -- `recordResult(db, subject, {solved, guessCount})` - - Increments `played`, `solved` (if solved), `totalGuesses += guessCount`. - - `bestGuessCount = min(bestGuessCount ?? ∞, guessCount)` on solved. - - Writes `lastResultAt = Date.now()`. - -## Files to edit - -### `src/modules/index.js` -Add one line to the static import map, alphabetically after `misc`: -```js -semantle: () => import("./semantle/index.js"), -``` - -### `wrangler.toml` -- In `[vars]` append `semantle` to `MODULES`. -- Add `WORD2SIM_API_URL = "https://word2sim.sg.miti99.com"` to `[vars]`. - -### `.dev.vars.example` -Add optional override: -``` -# Optional: override for local/self-hosted word2sim instance -# WORD2SIM_API_URL=http://localhost:8000 -``` - -## Implementation steps - -1. Create folder + empty stubs for all files. -2. Wire `src/modules/index.js` entry. -3. Wire `wrangler.toml` vars; confirm `npm run dev` boots without error. -4. Implement `api-client.js`; ad-hoc test with `wrangler dev` + curl to ensure the hosted instance responds. -5. Implement `state.js`. -6. `index.js` with placeholder handlers that return "not implemented yet". - -## Todo - -- [ ] `src/modules/semantle/` folder + empty files -- [ ] Register in `src/modules/index.js` -- [ ] Update `wrangler.toml` `MODULES` + `WORD2SIM_API_URL` -- [ ] Update `.dev.vars.example` with optional override comment -- [ ] Implement `api-client.js` (2 methods + `Word2SimError` + timeout) -- [ ] Implement `state.js` (load/save/clear + stats + recordResult) -- [ ] Placeholder `index.js` export + noop handlers -- [ ] `npm run dev` boots without errors; `/semantle` reply shows "not implemented" - -## Success criteria - -- Dev server starts with `semantle` listed in modules. -- Placeholder `/semantle` command responds in Telegram (polling via dev webhook or logs). -- `api-client.js` callable from a node REPL or test file against the live service. -- No biome/eslint warnings. - -## Risk - -- **Cloudflare Worker egress to word2sim** — ensure `fetch()` to the SG subdomain - is not blocked by any Worker networking policy. Expected fine; same pattern as - `trading/prices.js` and `lolschedule/api-client.js`. -- **KV size per game** — just target + guess history (a few KB even after hundreds - of guesses). Well under KV value limit. - -## Security - -- No secrets added; `WORD2SIM_API_URL` is a public endpoint. -- All user input goes into URL query params — rely on `URLSearchParams` encoding - to avoid injection; never concatenate user strings into URLs directly. - -## Next - -→ Phase 2 `phase-02-gameplay.md` — real handlers and rendering. diff --git a/plans/260422-2128-semantle-module/phase-02-gameplay.md b/plans/260422-2128-semantle-module/phase-02-gameplay.md deleted file mode 100644 index d396a96..0000000 --- a/plans/260422-2128-semantle-module/phase-02-gameplay.md +++ /dev/null @@ -1,140 +0,0 @@ -# Phase 2 — Gameplay - -Real handlers, guess lookup, board rendering, similarity formatting. -End of phase: module is fully playable in Telegram. - -## Context links - -- Overview: `./plan.md` -- Prior phase: `./phase-01-foundation.md` -- Pattern to mirror: `src/modules/loldle/handlers.js`, `src/modules/wordle/handlers.js` -- Render pattern: `src/modules/loldle/render.js` - -## Files to create - -### `src/modules/semantle/lookup.js` (~25 LOC) -- `normalize(raw) → string` — trim, collapse whitespace, lowercase. -- `isValidShape(word) → boolean` — reject empty, length > 64, non-ASCII-letters-only - (matches `/random` default filter; avoids wasted API round-trips). - -### `src/modules/semantle/format.js` (~30 LOC) -- `formatWarmth(similarity) → string` — signed percent: `Math.round(similarity * 100)` - shown as `+73` / `-04`. -- `progressBar(similarity) → string` — 10-cell unicode bar from `-1..1` - (use `░▓█`; helps visual scanning). Optional / can be skipped if time-boxed. -- `warmthEmoji(similarity)` — 🥶 (< 0.2) / 😐 (< 0.4) / 🌡️ (< 0.6) / 🔥 (< 0.8) / 🎯 (≥ 0.8) - -### `src/modules/semantle/render.js` (~70 LOC) -Telegram HTML `
` monospace block. Two public exports:
-- `renderBoard(guesses, latestIndex)` — sort by similarity desc, show at most top 15;
-  highlight the latest guess with a leading marker. Each row:
-  ```
-  #  warmth  word            emoji
-  1  +78     sea             🔥
-  2  +45     fish            🌡️
-  ```
-- `renderGuess(entry, position, total)` — single-line summary for a guess that
-  fell outside the rendered top-15: `"Your guess 'carpet' → +12"`.
-- Header line: `🎯 Semantle —  guesses`.
-- Footer when solved: `"✅ Solved in  guesses!"`.
-
-### `src/modules/semantle/handlers.js` (~170 LOC)
-One exported function per command:
-- `handleSemantle(ctx, deps)`
-- `handleGiveup(ctx, deps)`
-- `handleNew(ctx, deps)`
-- `handleStats(ctx, deps)`
-
-where `deps = { db, client }`. Shared helpers (subject resolution, arg parsing)
-copied from loldle with minimal change.
-
-**Flow for `/semantle `:**
-
-1. Resolve subject; reject if missing.
-2. `game = await loadOrStart(db, client, subject)` — lazy-init calls `client.randomWord(...)`;
-   target stored lowercased.
-3. Normalize the guess (trim, lowercase).
-4. `res = await client.similarity(game.target, guess)`.
-5. If `!res.in_vocab_b` → reply "🤔 unknown word" without appending.
-6. Append `{word:guess, canonical:res.canonical_b, similarity:res.similarity}`;
-   set `startedAt` if null; saveGame.
-7. If `res.canonical_b.toLowerCase() === game.target` → mark `solved`, `recordResult({solved:true, guessCount})`,
-   `clearGame`, reply with board + win message.
-8. Else reply with `renderBoard` — guess pool grows unbounded.
-
-**Flow for `/semantle` (no arg):**
-- If no active game → lazy-init (but don't call similarity; just show empty board
-  with "🆕 Round ready — send your first guess.").
-- Else → `renderBoard`.
-
-**Flow for `/semantle_new`:**
-- Load current game; if exists and has ≥1 guess, `recordResult({solved:false})`
-  and `clearGame`. Then lazy-init a fresh one; reply "🆕 New round started."
-
-**Flow for `/semantle_giveup`:**
-- If no active game → "No active round."
-- Else reveal `game.target`, `recordResult({solved:false, guessCount: guesses.length})`,
-  `clearGame`, reply.
-
-**Flow for `/semantle_stats`:**
-- `loadStats(subject)`, render:
-  - Played / Solved / Solve rate
-  - Total guesses / Best guess count (lowest number of guesses to solve)
-  - Average guesses per solve (if `solved > 0`)
-
-## Error handling
-
-- Wrap every `client.*` call in try/catch. On `Word2SimError` or fetch timeout:
-  reply `"⚠️ Upstream hiccup — try again in a few seconds."` and log the error
-  structured (`console.log(JSON.stringify({msg:"semantle_upstream_fail", ...}))`).
-- If `/random` fails, do NOT persist a partial game — user simply retries.
-
-## Implementation steps
-
-1. `lookup.js` first — pure logic, trivial to verify.
-2. `format.js` — pure logic, stub renderings.
-3. `render.js` — build HTML using phase-1 state shape.
-4. Replace placeholder handlers with real implementations, one command at a time:
-   `_stats` → `/semantle` (no-arg, empty board) → `/semantle ` → `_giveup` → `_new`.
-5. End-to-end manual test via `wrangler dev` + Telegram bot or `curl` against the
-   webhook endpoint.
-
-## Todo
-
-- [ ] `lookup.js` normalize + isValidShape
-- [ ] `format.js` formatWarmth, warmthEmoji, (optional) progressBar
-- [ ] `render.js` renderBoard + renderGuess
-- [ ] `handlers.js` subject resolver + arg parser (copy from loldle)
-- [ ] `handlers.js` handleStats (simplest path, ensures KV wiring works)
-- [ ] `handlers.js` handleSemantle no-arg path
-- [ ] `handlers.js` handleSemantle guess path (solve / OOV / score)
-- [ ] `handlers.js` handleGiveup
-- [ ] `handlers.js` handleNew
-- [ ] E2E smoke test in Telegram
-
-## Success criteria
-
-- `/semantle` with no arg shows a clean "round ready" message.
-- `/semantle apple` returns similarity within ~500ms p50.
-- `/semantle ` ends the round and updates stats.
-- `/semantle_giveup` reveals the target and clears state.
-- Out-of-vocab guess does not count against the guess tally.
-- Board stays readable up to 100+ guesses (render caps at top 15).
-
-## Risk
-
-- **Latency** — two KV reads + one fetch per guess. Target ≤ 800ms p95. If the
-  hosted word2sim cold-starts too slowly, add a periodic cron warmup later.
-- **File-size drift** — `handlers.js` at ~170 LOC is close to the 200 cap; if it
-  overruns, split `_stats` into its own `stats-handler.js` (pattern used by
-  trading module).
-
-## Security
-
-- Treat the guess string as untrusted: escape-html before rendering.
-- Do NOT leak `game.target` in any response path except `/semantle_giveup` and
-  `handleSemantle` win reply.
-
-## Next
-
-→ Phase 3 `phase-03-tests-docs.md` — coverage, README, `/help` registration.
diff --git a/plans/260422-2128-semantle-module/phase-03-tests-docs.md b/plans/260422-2128-semantle-module/phase-03-tests-docs.md
deleted file mode 100644
index d598fa2..0000000
--- a/plans/260422-2128-semantle-module/phase-03-tests-docs.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# Phase 3 — Tests & Documentation
-
-Ship-ready polish: unit-test coverage with fake KV + stubbed fetch, module README,
-`/help` integration verification.
-
-## Context links
-
-- Overview: `./plan.md`
-- Prior phases: `./phase-01-foundation.md`, `./phase-02-gameplay.md`
-- Test pattern: `tests/modules/wordle/` and `tests/modules/trading/` (fetch-stubbing examples)
-- Fakes: `tests/fakes/{fake-kv-namespace,fake-bot,fake-modules}.js`
-
-## Files to create
-
-### `tests/modules/semantle/api-client.test.js` (~50 LOC)
-Stub `global.fetch` with `vi.fn()`:
-- asserts query-string building (rank filters, URL encoding)
-- asserts `Word2SimError` on non-2xx
-- asserts AbortController timeout path (simulate slow upstream)
-
-### `tests/modules/semantle/state.test.js` (~60 LOC)
-Using `fake-kv-namespace`:
-- load/save round-trip preserves shape
-- clearGame removes entry
-- recordResult increments played/solved/totalGuesses correctly
-- bestGuessCount is `min(prev, current)` only when solved
-- loadStats returns defaults when empty
-
-### `tests/modules/semantle/format.test.js` (~25 LOC)
-Pure-function coverage:
-- formatWarmth signed/rounded
-- warmthEmoji buckets boundary cases
-
-### `tests/modules/semantle/render.test.js` (~40 LOC)
-- renderBoard empty state renders "round ready" prompt
-- renderBoard sorts by similarity desc (verify first row = highest score)
-- renderBoard caps to top 15 when >15 guesses
-- renderGuess escapes HTML-unsafe chars in the word
-
-### `tests/modules/semantle/handlers.test.js` (~130 LOC)
-Integration-ish: fake KV + stubbed client (not real fetch).
-- happy path: round start → guess → guess → solve (case-insensitive) → stats updated
-- OOV guess: not appended, no state mutation
-- _giveup reveals target and clears game
-- _new abandons prior round + clears, records non-solve
-- error from client surfaces a user-friendly message; state unchanged
-- case sensitivity: guess "APPLE" against target "apple" solves the round
-
-## Files to create (docs)
-
-### `src/modules/semantle/README.md`
-Mirror `src/modules/loldle/README.md` shape. Sections:
-- **Commands** table (from `plan.md`)
-- **Data source** — point at `tiennm99/word2sim` hosted instance
-- **Architecture** — list of files and what each does
-- **Storage** — KV layout table (`game:`, `stats:`)
-- **Config** — `WORD2SIM_API_URL` env var
-- **Credits** — word2vec / GoogleNews pretrained vectors
-
-## Files to edit
-
-### `docs/adding-a-module.md`
-No change expected unless the word2sim env-var pattern is the first of its kind.
-If so, add a short note about `[vars]` config for external API bases.
-
-### `scripts/register.js`
-Verify `setMyCommands` picks up the new public commands automatically (no code
-change expected — registry is the source of truth).
-
-## Todo
-
-- [ ] `api-client.test.js`
-- [ ] `state.test.js`
-- [ ] `format.test.js`
-- [ ] `render.test.js`
-- [ ] `handlers.test.js`
-- [ ] `src/modules/semantle/README.md`
-- [ ] Run `npm test` — all pass
-- [ ] Run `npm run lint && npm run format` — clean
-- [ ] `npm run register:dry` — confirms `/semantle`, `/semantle_giveup`, `/semantle_new`,
-      `/semantle_stats` appear in the command payload
-- [ ] Optional: `docs/adding-a-module.md` one-paragraph addition if env-var
-      pattern is novel
-
-## Success criteria
-
-- Test coverage for all new files (>80% line coverage, pragmatic thresholds).
-- No biome/eslint warnings (project enforces 100-char line width, sorted imports,
-  trailing commas).
-- `npm run register:dry` output includes all public `/semantle*` commands.
-- `src/modules/semantle/README.md` parallel to loldle's in shape and depth.
-
-## Risk
-
-- **Fetch stub drift** — if word2sim adds fields, tests may not catch breaking
-  response-shape changes in prod. Mitigation: add a single optional integration
-  test (guarded by env flag) that hits the real URL; skip by default.
-- **Flaky timing tests** — avoid real `setTimeout` in the AbortController test;
-  use `vi.useFakeTimers()` if needed.
-
-## Security
-
-- Tests must not commit real TELEGRAM tokens or call Telegram.
-- `.dev.vars` is gitignored; never add secrets to `.dev.vars.example`.
-
-## Next
-
-- Deploy via `npm run deploy` (handles webhook + setMyCommands).
-- Monitor the first day of usage via CF Worker logs; look for upstream errors.
-- Potential follow-ups (separate plans):
-  - Daily shared secret (Semantle-classic mode)
-  - Leaderboard module integration
-  - Webhook-side warm-up cron to keep word2sim hot
diff --git a/plans/260422-2128-semantle-module/plan.md b/plans/260422-2128-semantle-module/plan.md
deleted file mode 100644
index 3302cc3..0000000
--- a/plans/260422-2128-semantle-module/plan.md
+++ /dev/null
@@ -1,69 +0,0 @@
----
-name: semantle-module
-status: completed
-created: 2026-04-22
-completed: 2026-04-22
-slug: semantle-module
-blockedBy: []
-blocks: []
----
-
-# Semantle Module — miti99bot
-
-Add a Telegram game module mirroring `loldle`/`wordle`, but powered by word2vec
-cosine similarity via the hosted `word2sim` API. Unlimited guesses per round.
-
-**External dependency:** https://word2sim.sg.miti99.com/ (our own hosted instance;
-see `tiennm99/word2sim` repo).
-
-## Commands
-
-| Command | Description |
-|---------|-------------|
-| `/semantle` | show current board, or submit a guess (arg) |
-| `/semantle ` | submit a guess |
-| `/semantle_giveup` | reveal the secret and end the round |
-| `/semantle_new` | abandon round + start fresh (same as wordle pattern) |
-| `/semantle_stats` | show per-subject stats |
-
-## Key differences from loldle/wordle
-
-- **Unlimited guesses** — no MAX cap; round ends only on solve, giveup, or `_new`.
-- **Continuous score** — each guess returns cosine similarity ∈ [−1, 1] scaled to
-  0–100 "warmth" for display. No rank concept.
-- **Case-insensitive match** — target stored lowercase; guess canonical form is
-  lowercased before equality check.
-- **Network-bound** — calls word2sim per guess; needs graceful fallback.
-- **Stats model** — `{played, solved, totalGuesses, bestGuessCount}` (no streak,
-  since there is no loss state).
-
-## Phases
-
-| Phase | File | Focus | Est. LOC |
-|-------|------|-------|---------:|
-| 1 | `phase-01-foundation.md` | module scaffold, KV state, word2sim api-client, env wiring | ~220 |
-| 2 | `phase-02-gameplay.md` | handlers, lookup, render, format | ~310 |
-| 3 | `phase-03-tests-docs.md` | vitest coverage, README, help-command integration | ~180 |
-
-## Critical files
-
-- **Create:** `src/modules/semantle/{index,api-client,state,handlers,lookup,render,format}.js` + `README.md`
-- **Edit:** `src/modules/index.js` (register loader), `wrangler.toml` (MODULES list + `WORD2SIM_API_URL` var), `.dev.vars.example` (optional override)
-- **Test:** `tests/modules/semantle/*.test.js` (stub `global.fetch`)
-
-## Design decisions (locked in unless overturned)
-
-1. **Subject resolution** — same as loldle: user id in DMs, chat id in groups.
-2. **Round start** — call `/random` once to pick the target; store it lowercased.
-   No `/neighbors` call, no rank cache.
-3. **Random filters** — `/random?min_rank=500&max_rank=20000&min_len=4&max_len=10&alpha_only=true`.
-4. **Per guess** — one `/similarity?a=&b=` call. Solve when
-   `canonical_b.toLowerCase() === target` (case-insensitive exact match).
-5. **Board sort** — all guesses sorted by similarity desc; highlight latest guess.
-6. **OOV handling** — if `/similarity` returns `in_vocab_b: false`, reply "unknown word"
-   and do NOT append to guesses (no cost).
-7. **Env var** — `WORD2SIM_API_URL` in `wrangler.toml [vars]`, default `https://word2sim.sg.miti99.com/`.
-
-## Open questions
-
-- Per-chat daily shared secret (like the real Semantle) vs per-subject random? Defaulting to per-subject random — daily mode is a later add-on.
diff --git a/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md b/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md
deleted file mode 100644
index 9c7ec5a..0000000
--- a/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# Code Review — `semantle` module
-
-**Reviewer:** code-reviewer subagent
-**Date:** 2026-04-22
-**Scope:** new module under `src/modules/semantle/` + tests under `tests/modules/semantle/` + config edits (`wrangler.toml`, `.env.deploy`, `.dev.vars.example`, `src/modules/index.js`).
-**Verdict:** APPROVE_WITH_NITS
-**Score:** 9.6 / 10 (auto-approve threshold met)
-
----
-
-## Summary
-
-Well-scoped, focused module that mirrors the `loldle`/`wordle` patterns. Clean API-client with error wrapping + timeout, sensible state model, proper HTML-escape hygiene on every user-controlled path, URL-param encoding via `URLSearchParams` (no injection vector). Tests cover the happy + sad paths well. No critical or important bugs found. A handful of nits + test-coverage gaps noted below — none blocking.
-
----
-
-## Critical (blocking)
-
-None.
-
----
-
-## Important (non-blocking but worth filing)
-
-None.
-
----
-
-## Nits
-
-### N1. `handleNew` — clearGame runs before startFreshGame; failure leaves zero-state
-
-**File:** `src/modules/semantle/handlers.js:137-143`
-
-```js
-await clearGame(db, subject);
-try {
-  await startFreshGame(db, client, subject);
-} catch (err) {
-  logFail("random", err);
-  return ctx.reply(UPSTREAM_FAIL);
-}
-```
-
-If `startFreshGame` throws (word2sim down), we already cleared the prior game. Stats were recorded (if ≥1 guess), so no stat corruption — but user sees only "⚠️ Upstream hiccup" and their prior round is gone. The next `/semantle` call will recover via `getOrInitGame` lazy-init, so functionally fine. Mention only: acceptable as-is, worth a comment.
-
-**Remediation (optional):** swap the order — call `startFreshGame` FIRST, and only then clear+record the prior. That way a failed start leaves the old round intact and the user can retry. Tests would still pass as-is (they don't cover this ordering). Low priority.
-
-### N2. Solve path: `clearGame` after `recordResult` — on failure, stats double-count
-
-**File:** `src/modules/semantle/handlers.js:114-115`
-
-```js
-await recordResult(db, subject, { solved: true, guessCount: count });
-await clearGame(db, subject);
-```
-
-If `clearGame` throws but `recordResult` succeeded, a retried `/semantle ` on the still-persisted game (target still equals canonical → re-solve) would call `recordResult` again → played+=2, bestGuessCount stays same (min). Low impact, matches loldle pattern. No fix needed unless you care about rare KV-delete failures.
-
-### N3. `handleNew` branch unreachable for `existing.solved === true`
-
-**File:** `src/modules/semantle/handlers.js:131`
-
-```js
-if (existing && existing.guesses.length > 0 && !existing.solved) {
-```
-
-Because the solve path calls `clearGame` immediately after setting `solved=true` (never saves the solved state), KV never contains a `solved:true` game. The `!existing.solved` guard defends against a state that structurally can't exist. Harmless defensive code but not exercised by any test. Keep as-is.
-
-### N4. `docs/adding-a-module.md` example MODULES list is stale
-
-**File:** `docs/adding-a-module.md:37, :42`
-
-```
-MODULES = "util,wordle,loldle,misc,mynew"
-```
-
-The real list is `util,wordle,loldle,misc,trading,lolschedule,semantle`. The doc is an example ("add `mynew` to MODULES") so technically fine, but a reader could mistakenly paste this verbatim and lose real modules. Out of scope for this PR; file separately or ignore. **Not a blocker.**
-
-### N5. `README.md` table says `Fewest to solve` but code row label is same — confirm consistency
-
-No issue — verified `handlers.js:182` matches `Fewest to solve: …`. Ignore.
-
-### N6. Board width formula `Math.max(...sorted.map(...))` on empty `sorted`
-
-**File:** `src/modules/semantle/render.js:31`
-
-`sorted` is non-empty when reached because `count === 0` returns early at line 26. Safe. Document inline if you want to make the invariant explicit.
-
-### N7. `api-client.js` — `fetch` failure path leaks the underlying error stack through `err.cause`
-
-**File:** `src/modules/semantle/api-client.js:45-48`
-
-`Word2SimError` preserves `cause: err`. `handlers.js:logFail` serializes via `String(err)` — only the top message, not `err.cause`. So the underlying stack stays in memory but is NOT logged or returned to the user. Good — not a data leak. Worth noting in the file header if you want to document the stance.
-
-### N8. Group chat concurrency (expected)
-
-**File:** `src/modules/semantle/handlers.js:122, 107-108`
-
-Two rapid `/semantle ` in a group chat race: both read same state → both write back. Losing guess is possible. Same pattern as loldle/wordle; acceptable for a low-stakes game. No CAS/lock needed. Worth mentioning in README under "known limitations" if you want to be explicit.
-
----
-
-## Test coverage holes (minor)
-
-Current 90 tests cover ≥95% of branches. Gaps noted (not blocking — file as follow-up test cases if desired):
-
-### T1. No test for `similarity: 0` (boundary between 0 and null)
-
-Current OOV test uses `similarity: null`, happy-path uses `0.45`. A guess that word2vec rates at exactly `0.0` should be ACCEPTED (not OOV). Code is correct (`res.similarity == null` is false for 0), but untested.
-
-**Suggested add:** one test in `handlers.test.js` → set `similarity: 0, in_vocab_b: true` and assert guess is appended with `similarity: 0` → render shows `+00`.
-
-### T2. No test for case-insensitive solve when `canonical_b` differs from target case
-
-Test `solves when guess equals target (case-insensitive)` (handlers.test.js:120) sends `APPLE` but mock returns `canonical_b: "apple"`. Good. But there's no test verifying the `canonical_b.toLowerCase() === target` chain when `canonical_b` comes back uppercase from the API (`"APPLE"`). Code does `String(res.canonical_b ?? guess).toLowerCase()` (handlers.js:103) so it's safe; just untested.
-
-### T3. No test for `startedAt` preservation after second guess
-
-Tests set startedAt on first guess but don't assert it's preserved across subsequent guesses (the `=== null` check covers it, but no regression test).
-
-### T4. No test for duplicate-canonical across different raw inputs
-
-E.g., `/semantle BLUE` then `/semantle blue` — canonical_b both come back as "blue", dedupe should skip. Implicit in the normalize path but untested directly.
-
-### T5. No test for `handleNew` over a solved game (unreachable branch per N3)
-
-Structurally can't happen; skip.
-
----
-
-## Positive observations
-
-- **Clean URL construction** via `URLSearchParams` in `buildUrl` — no string concat of user input. Filters out `undefined/null` params defensively.
-- **Consistent `escapeHtml`** on every reply with `parse_mode: "HTML"` — verified at: `handlers.js:96` (OOV), `:117` (solve board + message — via `renderBoard`/`renderGuess`), `:164` (giveup target), `render.js:36/50` (canonical word). No leak path to Telegram HTML parser.
-- **Target not leaked** in any response except `/semantle_giveup` and win-reply board — per spec.
-- **`Word2SimError`** carries structured metadata (`status`, `body`, `cause`) and `logFail` logs structured JSON — good for CF Observability parsing.
-- **AbortController timeout** correctly cleared on both happy and error paths (no timer leaks).
-- **Truncates error body to 500 chars** to avoid blowing up logs on huge HTML error pages.
-- **Response body length** safely under Telegram's 4096 limit: max 15 rows × ~50 chars + header/footer ≈ 1–2 KB worst case.
-- **Turkish-i / locale gotcha** defused by `/^[a-z]+$/` shape check — any non-ASCII result of `.toLowerCase()` (Turkish `İ → i̇`) is rejected before hitting the API.
-- **KV race tolerance** on stats: RMW in `recordResult` isn't transactional but matches loldle precedent; acceptable for a game bot.
-- **Plan spec compliance:** all phase-01/02/03 requirements met. Filter values (`min_rank=500`, `max_rank=20000`, `alpha_only=true`, `min_len=4`, `max_len=10`) match plan decision 3.
-- **Help command integration is automatic** — `help-command.js` reads from the registry, no manual wiring needed. `npm run register:dry` confirms 4 public commands appear.
-- **No `.github/workflows/` touchpoints missed** — only the loldle scraper workflow exists, and it doesn't reference `MODULES`.
-
----
-
-## Metrics
-
-- Source LOC: ~410 (handlers 188, state 98, api-client 94, render 52, format 30, lookup 21, index 56) — all files under 200-line limit.
-- Test LOC: ~910 across 5 files, 90 test cases.
-- External touchpoints correctly updated: `wrangler.toml` (MODULES + WORD2SIM_API_URL), `.env.deploy*` (MODULES), `.dev.vars.example` (optional override), `src/modules/index.js` (import map entry).
-- No new secrets committed; WORD2SIM_API_URL is a public endpoint.
-- Lint / typecheck / tests all clean per task context (not re-run).
-
----
-
-## Recommended actions (prioritized)
-
-1. **(optional)** Add one test for `similarity: 0` boundary (T1).
-2. **(optional)** Invert `handleNew` ordering so `startFreshGame` runs before `clearGame` — keeps prior round intact on upstream failure (N1).
-3. **(future PR)** Refresh `docs/adding-a-module.md` example MODULES list (N4).
-
-None are blockers. Ship it.
-
----
-
-## Unresolved questions
-
-None.
-
----
-
-**Status:** DONE
-**Summary:** semantle module is production-ready; no critical or important issues found. Test coverage is strong (90 cases, ~95% branches). Minor nits and a couple of optional test additions noted.
-**Score:** 9.6 / 10 — auto-approve threshold met.
diff --git a/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md b/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md
deleted file mode 100644
index 3e0502f..0000000
--- a/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md
+++ /dev/null
@@ -1,210 +0,0 @@
-# Semantle Module Test Report
-
-**Date:** 2026-04-22 | **Time:** 21:55 | **Test Suite:** vitest 4.1.4
-
----
-
-## Test Execution Summary
-
-**Status:** ✅ ALL PASS
-
-**Test files created:** 5  
-**Total tests added:** 90  
-**Total LOC:** 1,214 (including docstrings and structure)
-
-### Breakdown by file:
-- `api-client.test.js` — 173 LOC, 13 tests
-- `state.test.js` — 206 LOC, 19 tests
-- `format.test.js` — 73 LOC, 12 tests
-- `render.test.js` — 181 LOC, 17 tests
-- `handlers.test.js` — 581 LOC, 29 tests
-
----
-
-## Coverage Results
-
-All tests pass:
-- **api-client.test.js**: 13/13 ✅
-- **state.test.js**: 19/19 ✅
-- **format.test.js**: 12/12 ✅
-- **render.test.js**: 17/17 ✅
-- **handlers.test.js**: 29/29 ✅
-
-**Full suite health:** 340/340 tests pass (no regressions)
-
----
-
-## Test Coverage by Module
-
-### `api-client.js` (13 tests)
-- ✅ Word2SimError metadata storage (status, body, cause)
-- ✅ URL building with query params
-- ✅ Parameter URL encoding
-- ✅ Error on non-2xx response (status + truncated body capture)
-- ✅ Error on invalid JSON response
-- ✅ Error on fetch failure (network, timeout)
-- ✅ Custom timeout handling
-- ✅ User-Agent and Accept headers
-- ✅ Trailing slash normalization
-- ✅ Undefined/null param filtering
-- ✅ randomWord() and similarity() endpoints
-
-### `state.js` (19 tests)
-- ✅ saveGame/loadGame round-trip integrity
-- ✅ Return null for non-existent games
-- ✅ Overwrite on second save
-- ✅ Preserve null startedAt
-- ✅ clearGame removes entries (idempotent)
-- ✅ loadStats defaults (all zeros, bestGuessCount:null)
-- ✅ recordResult increments played on every call
-- ✅ recordResult accumulates totalGuesses
-- ✅ recordResult increments solved only on win
-- ✅ bestGuessCount = min(prev, current) on wins only
-- ✅ bestGuessCount stays null for loss-only history
-- ✅ lastResultAt timestamp recording
-- ✅ recordResult returns updated stats
-
-### `format.js` (12 tests)
-- ✅ formatWarmth: positive (+73), negative (-04), zero (+00)
-- ✅ formatWarmth: rounding to nearest int
-- ✅ formatWarmth: boundary cases
-- ✅ warmthEmoji: 🥶 < 0.2
-- ✅ warmthEmoji: 😐 [0.2, 0.4)
-- ✅ warmthEmoji: 🌡️ [0.4, 0.6)
-- ✅ warmthEmoji: 🔥 [0.6, 0.8)
-- ✅ warmthEmoji: 🎯 >= 0.8
-
-### `render.js` (17 tests)
-- ✅ Empty board shows "round ready" prompt
-- ✅ Singular/plural "guess" / "guesses"
-- ✅ Sort guesses by similarity DESC
-- ✅ Cap display at top 15, hide older with footer
-- ✅ Singular/plural "older guess" / "guesses"
-- ✅ Latest guess marked with ➡️, others with spaces
-- ✅ HTML entity escaping in canonical words
-- ✅ Warmth emoji in each row
-- ✅ No footer when exactly 15 guesses
-- ✅ Returns HTML `
` block format
-- ✅ renderGuess single-line summary
-- ✅ renderGuess escapes HTML special chars
-- ✅ renderGuess wraps in `` tags
-- ✅ renderGuess includes emoji and signed percent
-
-### `handlers.js` (29 tests)
-**Flow tests:**
-- ✅ Start new round with no args
-- ✅ Show board after fresh start
-- ✅ Reuse existing unsolved game
-- ✅ Start fresh after solve
-- ✅ Submit guess and append to board
-- ✅ Solve on case-insensitive match
-- ✅ Clear game + record result on solve
-- ✅ Reject invalid shape guess (no API call)
-- ✅ Reject OOV guess, don't persist to board
-- ✅ Deduplicate re-submitted words
-- ✅ Set startedAt on first guess
-- ✅ Include latest-guess marker in renders
-- ✅ Normalize guess to lowercase before API
-- ✅ Normalize whitespace in arguments
-
-**Error handling:**
-- ✅ Reply UPSTREAM_FAIL on randomWord error
-- ✅ Reply UPSTREAM_FAIL on similarity error
-- ✅ Handle missing subject (cannot identify chat)
-
-**Group chat:**
-- ✅ Group chat resolves to chat.id (shared game)
-- ✅ Private chat resolves to user.id (per-user)
-
-**handleNew tests (5):**
-- ✅ Start fresh with no prior game
-- ✅ Abandon unsolved game + record non-solve
-- ✅ Don't record if game had zero guesses
-- ✅ Reply UPSTREAM_FAIL on error
-- ✅ Handle group chat
-
-**handleGiveup tests (4):**
-- ✅ Reveal target and clear game
-- ✅ Record non-solve result
-- ✅ Reply "no active round" when none exists
-- ✅ Escape HTML in target reveal
-
-**handleStats tests (5):**
-- ✅ Show default message for new user
-- ✅ Show stats after games
-- ✅ Show "—" for bestGuessCount when no solves
-- ✅ Calculate solve percentage correctly (e.g., 50%)
-- ✅ Format average guesses per round
-- ✅ Include HTML formatting in reply
-
----
-
-## Key Edge Cases Tested
-
-1. **Case sensitivity**: Guess "APPLE" solves against target "apple" ✅
-2. **OOV handling**: Words not in vocab rejected without board mutation ✅
-3. **Deduplication**: Re-submitted words don't inflate board or stats ✅
-4. **HTML escaping**: `