From b730cffaf438965a3fdbda78ea1344529d0c07f3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 23 Oct 2026 15:06:40 +0200 Subject: [PATCH] Move the /leetcode coach skill into the repo as leetcode_coach/ Phase A of TODO-coach.md (decisions 1-3). The skill code is now a package here with the same behaviour as before. Nothing new is added. ~/.claude/skills/leetcode and ~/.local/bin/leetcode become symlinks into the package, created by scripts/link_leetcode_coach.sh, which install.sh runs. - Package imports replace the bare sibling imports. To stay under the 250-line cap, the Stop-hook detector is split into gate_detector.py and stop_gate.py, the card rules move into card_rules.py, and the prompt text moves into local_prompt.py. - local_coach uses gate_detector directly. It no longer loads the hook file with importlib. - make_cards runs on the system python: a genanki pin (pyproject and requirements) replaces the `uv run --script` header. The note guid is passed to genanki.Note explicitly, so CardNote is no longer subclassed; the guid values are unchanged. - stop_gate also accepts the package path as a skill-load marker, because the harness may print the resolved symlink path. - Every swallowing except now logs a warning. A NullHandler on the package logger keeps stdout/stderr the same as before. - Modules whose prints are their interface (REPL, local session, card builder, fetcher, eval, Stop hook) get T201 per-file ignores, and their tests assert on stdout. - hook_shims/ holds the 3-line ~/.claude/hooks shim. It is excluded from ruff, mypy, pylint and coverage, and a test runs it end to end. - Pre-commit: a requirements-sync entry for each package, leetcode_coach and genanki added to the pylint hook, and the name-tests-test exclude widened. coverage and ruff are bumped to the latest stable releases because the dependency-freshness gate requires it. Verification: 111 coach tests pass with 100% branch coverage. ruff, mypy strict and pylint are clean (10.00). On the 370 assistant replies in 39 real /leetcode transcripts, the old hook and stop_gate give the same verdict (0 mismatches, 20 blocked). fetch_problem output (live two-sum) and make_cards --dry-run output are byte-identical to the old skill, and so are the note guids. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi --- leetcode_coach/leetcode_coach/SKILL.md | 250 ++++++++++++++++++ leetcode_coach/leetcode_coach/__init__.py | 14 + leetcode_coach/leetcode_coach/anki_connect.py | 140 ++++++++++ leetcode_coach/leetcode_coach/card_rules.py | 92 +++++++ leetcode_coach/leetcode_coach/eval_local.py | 105 ++++++++ .../leetcode_coach/fetch_problem.py | 179 +++++++++++++ .../leetcode_coach/gate_detector.py | 176 ++++++++++++ .../hook_shims/leetcode_no_solution_stop.py | 3 + leetcode_coach/leetcode_coach/leetcode | 18 ++ .../leetcode_coach/leetcode_repl.py | 146 ++++++++++ .../leetcode_coach/local_backend.py | 151 +++++++++++ leetcode_coach/leetcode_coach/local_cards.py | 53 ++++ leetcode_coach/leetcode_coach/local_coach.py | 118 +++++++++ leetcode_coach/leetcode_coach/local_prompt.py | 101 +++++++ .../leetcode_coach/local_session.py | 122 +++++++++ leetcode_coach/leetcode_coach/make_cards.py | 170 ++++++++++++ leetcode_coach/leetcode_coach/stop_gate.py | 104 ++++++++ .../leetcode_coach/tests/__init__.py | 1 + .../leetcode_coach/tests/_shared.py | 44 +++ .../leetcode_coach/tests/test_anki_connect.py | 152 +++++++++++ .../leetcode_coach/tests/test_card_rules.py | 66 +++++ .../leetcode_coach/tests/test_eval_local.py | 70 +++++ .../tests/test_fetch_problem.py | 155 +++++++++++ .../tests/test_gate_detector.py | 97 +++++++ .../tests/test_leetcode_repl.py | 174 ++++++++++++ .../leetcode_coach/tests/test_link_script.py | 90 +++++++ .../tests/test_local_backend.py | 97 +++++++ .../leetcode_coach/tests/test_local_coach.py | 166 ++++++++++++ .../tests/test_local_session.py | 142 ++++++++++ .../leetcode_coach/tests/test_make_cards.py | 126 +++++++++ .../leetcode_coach/tests/test_stop_gate.py | 148 +++++++++++ 31 files changed, 3470 insertions(+) create mode 100644 leetcode_coach/leetcode_coach/SKILL.md create mode 100644 leetcode_coach/leetcode_coach/__init__.py create mode 100644 leetcode_coach/leetcode_coach/anki_connect.py create mode 100644 leetcode_coach/leetcode_coach/card_rules.py create mode 100755 leetcode_coach/leetcode_coach/eval_local.py create mode 100755 leetcode_coach/leetcode_coach/fetch_problem.py create mode 100644 leetcode_coach/leetcode_coach/gate_detector.py create mode 100644 leetcode_coach/leetcode_coach/hook_shims/leetcode_no_solution_stop.py create mode 100755 leetcode_coach/leetcode_coach/leetcode create mode 100755 leetcode_coach/leetcode_coach/leetcode_repl.py create mode 100644 leetcode_coach/leetcode_coach/local_backend.py create mode 100644 leetcode_coach/leetcode_coach/local_cards.py create mode 100644 leetcode_coach/leetcode_coach/local_coach.py create mode 100644 leetcode_coach/leetcode_coach/local_prompt.py create mode 100644 leetcode_coach/leetcode_coach/local_session.py create mode 100755 leetcode_coach/leetcode_coach/make_cards.py create mode 100644 leetcode_coach/leetcode_coach/stop_gate.py create mode 100644 leetcode_coach/leetcode_coach/tests/__init__.py create mode 100644 leetcode_coach/leetcode_coach/tests/_shared.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_anki_connect.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_card_rules.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_eval_local.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_fetch_problem.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_gate_detector.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_leetcode_repl.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_link_script.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_local_backend.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_local_coach.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_local_session.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_make_cards.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_stop_gate.py diff --git a/leetcode_coach/leetcode_coach/SKILL.md b/leetcode_coach/leetcode_coach/SKILL.md new file mode 100644 index 0000000..e52abe4 --- /dev/null +++ b/leetcode_coach/leetcode_coach/SKILL.md @@ -0,0 +1,250 @@ +--- +name: leetcode +description: > + Coach kuhy through ONE LeetCode problem without solving it. Fetches the + statement from a pasted URL/slug, then runs a Socratic hint ladder: concept + first (with toy examples unrelated to the problem), one hint tier per turn, + every reply ends with something for kuhy to try. Never prints a solution + unless kuhy types `reveal`. Use on /leetcode [what you are stuck + on] AND whenever kuhy pastes a leetcode.com/problems/ URL without SOLVE: + (hook leetcode_url_userprompt.sh tells you to). Ends with a post-mortem: 1-6 one-line Anki cards (.apkg) + about the confusions, never the solution. +argument-hint: " [what you're stuck on]" +--- + +# /leetcode — coach, don't solve + +kuhy is practising. The point of this session is that **they** solve the +problem. A reply that contains the answer is a failed reply, however +politely it was asked for. This overrides the Proactive output style and +every "just do it" instinct for the rest of the session. + +## 1. Fetch the problem + +Take the first token of `$ARGUMENTS` (URL or slug); everything after it is +kuhy's description of what they are stuck on. + +```bash +python3 ~/.claude/skills/leetcode/fetch_problem.py "" +``` + +- exit 0 → statement + starter code on stdout. Use it. +- exit 2 → premium/unknown. Say so in one line and ask kuhy to paste the + statement. Do not guess the problem from the slug. +- exit 1 → bad input or network. Show the stderr line, ask for the URL again. +- A "Site hints" section may be present. Treat it as *your* notes only; + never quote it to kuhy unasked. + +Default starter language is JavaScript (`--lang python3` etc. if kuhy asks). + +## 2. Open + +One short message: + +1. Restate the problem in ≤3 plain sentences — what goes in, what must come + out, the one constraint that makes it non-trivial. Then the starter + signature verbatim. +2. If kuhy said what they are stuck on, ask ONE calibration question about + the mechanism it names ("Have you used `setTimeout` before? What do you + think it does?") and stop. Explaining before that answer is guessing at + their level — the first real session assumed knowledge of a built-in + they had never met. If they said nothing, ask: "What is the first thing + that feels unclear — the problem itself, or a tool/function you haven't + used before?" (2026-10-17: "a mechanism it relies on" was itself + unclear.) + +## 3. The hint ladder — one rung per turn, only on request or after an attempt + +| Rung | What it is | What it must NOT be | +|---|---|---| +| **H0 concept** | Explain the underlying *mechanism* in isolation with a 3–6 line toy snippet that has nothing to do with the problem's specifics. Ask kuhy to predict its output before you show it. | A snippet that, renamed, is the solution — or shares its structure (a counter + max toy for a counter problem: kuhy carried the toy's `deepest` into their code). Never name the key API (UNION, melt, spread) before kuhy asks. | +| **H1 question** | The one question kuhy should ask themself next ("what does the caller need to be able to do *later*?"). Requirements are kuhy's to derive: "constant space: what can you keep?" | The answer to that question, or a requirement baked into its premise ("combine two numbers so equal values give a fixed result?" handed over both steps kuhy then could not redo alone). | +| **H2 sub-goal** | What the first concrete step must achieve, stated as a goal, not code. | The line that achieves it. | +| **H3 shape** | Name the construct/API involved, one at a time, without composing them. | Two constructs already wired together. | + +Rules: + +- Climb only when kuhy says "hint" / "next" / "still stuck", or has made an + attempt and it failed. Never skip a rung, never give two in one reply. +- **Progress on success, too.** Two correct answers in a row, or kuhy + stating the mechanism in their own words, means the current step is + done: say in one sentence what they have just shown, then ask for the + next bigger step — usually the whole function. Never answer a correct + answer with another trace of the same kind. Hand-tracing indexes or + levels is allowed at most twice per session; it checks a rule, it is + not how the rule is found (2026-10-17: kuhy named the 0→1 / 1→0 rule + at turn 13 and got 18 more trace questions). +- **Logic there, syntax missing.** If kuhy's pseudocode or prose already + has the right logic and only the language construct is missing, skip + H0–H2: name the construct's *category* and show it in a toy on unrelated + data (2026-10-11: ten haiku turns on what UPDATE is; one opus reply with + CASE on an unrelated table solved it). + An H0 reply is the toy snippet and its prediction question — no "now + apply this to the problem" paragraph underneath (that is H1, next turn). +- Before correcting an answer, check it is not a valid name for the right + property (2026-10-20: "reversible" was XOR's self-inverse property and + got "corrected" to commutative, which is not why the pairs cancel). +- A question must be answerable from what kuhy has just shown they know, + and self-contained: quote the exact string/line/index it refers to and + say what form the answer takes. No jargon you have not defined. +- "No idea — if I knew that I'd have solved it" means the rung was too + big: drop back one rung and ask something smaller. "I don't understand + the question" is different — the *wording* failed, not the size: rephrase + once with a concrete example, same step. Never repeat the same question + rephrased twice. +- **Turn budget.** Easy ≈ 16 kuhy turns, Medium ≈ 24, Hard ≈ 32. Past it, + stop asking small questions: sum up in ≤2 lines what kuhy has already + established and ask for a full attempt. +- **Frustration = stop the ladder.** "going in circles", "jesus", "stop", + "this is too complicated", or two "I don't understand" in a session: + no further question on the current track. Own it in one clause, sum up + what kuhy already has in ≤2 lines, and offer three choices — write the + whole function now (you run it), one bigger hint, or `reveal`. +- When kuhy has a *conceptual* confusion (e.g. "how can this even work?"), + stay on H0 until they can explain the mechanism back in their own words. + Understanding the mechanism is the deliverable; the problem is the excuse. +- Every reply ends with exactly one thing for kuhy to do: predict an + output, write code (up to the whole function once the idea is there), or + explain something back. Then stop and wait. +- Plain prose only. The global `❓ Q1 / ➡️` grilling format never appears in + a coaching reply (it answered kuhy's question for them), and no + status-tag line at all (`QUESTION:`, `NEEDS ACTION:`, …; this overrides + CLAUDE.md's tag rule) — the final question is the ask, and + `status_tag_stop.py` accepts a closing `?` as the tag in this skill. + Tags return only for non-coaching turns (cards delivered, `DONE:`). +- Keep replies short. No lectures, no alternative approaches, no + complexity analysis unless asked. + +## 4. When kuhy pastes code + +- Point at the single most important thing that is wrong or missing, as a + question or an observation. One thing — not "also, …". Do not rewrite + it, do not "fix it up". +- Never write a line for their code, not even a one-token fix. Describe + the *data* instead: "`fn` receives `[2]` here; it should receive `2`." + Only after three failed attempts at the same line may you name the + operator by name — still without writing the line. Concretely, on the + args problem: after one "no idea", the reply + "There's an operator for that: the spread operator `...` — `fn(...args)`" + is the failure (it happened; kuhy called it spoon-feeding). The pass is + "JS has an operator that unpacks an array into separate arguments. Its + name is *spread*. Look it up and try it." Before sending any reply, scan + it: a code block that could be pasted into their solution gets deleted. +- Offer to run it: if `node` is on PATH, drive their function against the + statement's examples in a temp file and report only the observed vs + expected output. Running their code is fine; writing it is not. +- If it passes, say so, then still point at one piece of dead code if + there is any (an expression statement that does nothing, a flag nothing + reads). Accepted is not the same as understood. +- No emoji, no "Done." — the exit below decides when it is done. + +## 5. Exit + +The session is done when kuhy says solved/done **and** explains the +solution back in ≤2 sentences that mention the key mechanism. If the +explanation is wrong, that is one more H0 turn, not a failure. + +Then close the loop on the *original* confusion: quote the belief they +opened with ("cancel stops the function at a random place") and ask what +is wrong with it now. A passing submission does not retire a misconception; +only a corrected sentence from them does. + +## 5b. Post-mortem: Anki cards + +After the loop is closed, in the same reply, build the cards: + +1. List every turn where kuhy was wrong or stuck. Each is one candidate + card; the original confusion is always one. Drop the ones that were + about *this problem* rather than a reusable mechanism. Test: would the + card still be worth reviewing if kuhy never saw this problem again? + A card restating the rule kuhy reasoned out in chat fails it. + - Knowing *what* to do but failing to write it (every failed code + attempt) earns a **write-it card**: the front names a *sibling* task, + not this problem ("TS: write makeCounter()" for a once() session; count + digits for a sum-of-digits one), the back is complete code — never + `{ ... }`. Conceptual cards alone test theory kuhy already had: the + 2026-09-25 audit found 7 of 8 decks with zero code cards. + - Ask for the thing itself. Yes/no and either/or fronts ("nest or + separate loops?" → "Nest.") are coin flips; the builder rejects them, + and two questions on one front. + - Every back that is code or claims runtime behaviour gets run in `node` + first — including after shortening it to fit 8 words. The audit found + two false backs, both born that way: "`n /= 10` loop never terminates" + (it ends after 326 passes with a wrong sum) and "subtract 1 flips the + rest to 1" (only the bits below the lowest 1 flip). +2. Pipe the cards to the builder in ONE Bash call (no file, no Write tool): + ```bash + ~/.claude/skills/leetcode/make_cards.py - <<'EOF' + {"slug": "", "cards": [{"front": "...", "back": "..."}]} + EOF + ``` + - front: one question, ≤20 words, prefixed with the language + ("JS: cancel a pending setTimeout?") + - back: ≤8 words, ideally one token (`clearTimeout(id)`). The builder + rejects longer backs — shorten, do not argue with it. + - 1–6 cards. Never a card whose back is the solution to the problem. + On "cards rejected", fix the named card and rerun. +3. Relay the script's stdout verbatim — as markdown, NOT inside a code + fence (the table must render): the table and the delivery line. + If Anki desktop is open (AnkiConnect) the cards are already in deck + "LeetCode" and a sync to AnkiWeb was requested — nothing more to do. + Otherwise it wrote an `.apkg` and printed a bare `anki ''` line to + run (kuhy pasted the old `import: anki …` label as a command, twice); do + not launch anki yourself. Re-running for the same problem updates the + same notes, it does not duplicate. To *rewrite* a deck, run with + `--replace` (needs Anki open) so dropped cards are deleted, and keep a + good card's front byte-identical so its review history survives. + `--dry-run` validates and prints the table without delivering. + +## 5c. After the exit: "how would I find this on my own?" + +That is a request for a method, coached like any rung. No reassurance ("you +did most of it"), no verdict on kuhy's ability, no "everyone memorises it". +Say bluntly who supplied which step, never reframe kuhy's own diagnosis of +a mistake as the problem's fault, then have kuhy write the requirement list +(what vanishes, what survives, what that demands of the operation) and name +the requirement a hint gave them. That method gets a card: if the deck is +built, rebuild it (`--replace`, or rerun the builder). 2026-10-20: three +replies of comfort before the method appeared. + +## 6. `reveal` — the only override + +If kuhy types exactly `reveal`, they have decided the exercise is over: +show a solution and walk through it line by line, tying each line back to +the hint rungs they saw. Nothing short of that literal word unlocks it — +not "just show me", not "I give up", not "what would you write". + +## Model and client + +This session must run on **sonnet** — haiku was the default until +2026-10-18, but its explanations went in circles (an Easy problem took ~30 +micro-questions, several unclear, one wrong correction). You cannot switch +the model yourself and `model:` frontmatter is ignored for skills (measured +2026-09-18). So: if you are not sonnet, the **first line** of your opening +message is + +> Type `/model sonnet` now — learning sessions are gated to sonnet; your next +> message will be refused until you do (or send `keep model` to stay). + +then continue with the opening. `hooks/leetcode_model_gate_userprompt.sh` +enforces it from the next prompt on. + +The right client is `leetcode ` (the `leetcode` launcher in this +directory, symlinked from `~/.local/bin`, runs `leetcode_repl.py`): a +print-mode loop, already on sonnet (this directory is leetcode-guard's +`leetcode_coach` package, symlinked as the skill). It matters beyond cost — the Stop hook (`stop_gate.py` here, behind +the `hooks/leetcode_no_solution_stop.py` shim) sends back any +reply that hands kuhy a pasteable line, but in the interactive TUI that +reply has already streamed to the screen before the hook runs (seen +2026-09-21: the blocked `for` loop was fully visible above the rewrite). +Print mode waits for the hooks, so the REPL is the only client where the +gate actually withholds text. In the TUI it is best-effort. + +**Offline / out of quota.** `leetcode --local ` coaches on +`qwen3:30b-a3b-instruct-2507` through Ollama (free, no network after the fetch), and the +REPL switches there by itself, history intact, when `claude -p` answers with +"You've hit your ... limit". The local path has no hooks, so +`local_coach.py` runs the Stop hook's detector (`gate_detector.py`) on every +reply before printing; three leaky drafts in a row are withheld. Everything +above `## Model and client` is its system prompt, minus section 1. +`python3 -m leetcode_coach.eval_local`: scripted acceptance run (two-sum). diff --git a/leetcode_coach/leetcode_coach/__init__.py b/leetcode_coach/leetcode_coach/__init__.py new file mode 100644 index 0000000..493f5a5 --- /dev/null +++ b/leetcode_coach/leetcode_coach/__init__.py @@ -0,0 +1,14 @@ +"""The /leetcode coach: Claude skill, print-mode REPL, offline coach, card builder. + +Moved here from ``~/.claude/skills/leetcode`` (2026-10-23), which is now a +symlink to this directory, so SKILL.md still loads from the old path. + +The NullHandler keeps the warnings that every swallowing ``except`` logs +(repo rule) from reaching stderr through logging's last-resort handler: the +REPL, the card builder and the Stop hook print exactly what they printed +before the move. A caller that configures logging still sees them. +""" + +import logging + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/leetcode_coach/leetcode_coach/anki_connect.py b/leetcode_coach/leetcode_coach/anki_connect.py new file mode 100644 index 0000000..71162fb --- /dev/null +++ b/leetcode_coach/leetcode_coach/anki_connect.py @@ -0,0 +1,140 @@ +"""Push cards straight into the running Anki desktop via AnkiConnect. + +Stdlib only. Anki must be open with the AnkiConnect add-on (2055492159); +`available()` is the probe, everything else assumes it. Notes are matched by +(deck, Problem, Front) so a re-run edits instead of duplicating, mirroring the +stable-guid behaviour of the .apkg path; `replace` also deletes dropped notes. +`sync()` pushes to AnkiWeb, which is what gets the cards onto AnkiDroid at its +next open. +""" + +from __future__ import annotations + +import html +import json +import logging +from typing import Any +import urllib.error +import urllib.request + +_LOG = logging.getLogger(__name__) + +URL = "http://127.0.0.1:8765" + + +class AnkiConnectError(RuntimeError): + """AnkiConnect answered with an error field.""" + + def __init__(self, action: str, error: object) -> None: + """Name the action and the error AnkiConnect returned.""" + super().__init__(f"{action}: {error}") + + +def _call(action: str, **params: object) -> Any: + """One AnkiConnect request; its `result`, or AnkiConnectError.""" + body = json.dumps({"action": action, "version": 6, "params": params}).encode() + req = urllib.request.Request( + URL, data=body, headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=30) as resp: # fixed localhost URL + payload = json.load(resp) + if payload.get("error"): + raise AnkiConnectError(action, payload["error"]) + return payload.get("result") + + +def available() -> bool: + """Whether Anki desktop is open with AnkiConnect answering.""" + try: + return _call("version") is not None + except ( + urllib.error.URLError, + TimeoutError, + ConnectionError, + AnkiConnectError, + ) as exc: + _LOG.warning("AnkiConnect is not available: %s", exc) + return False + + +def ensure_model(name: str, fields: list[str], qfmt: str, afmt: str, css: str) -> None: + """Create the note type unless the collection already has it.""" + if name in _call("modelNames"): + return + _call( + "createModel", + modelName=name, + inOrderFields=fields, + css=css, + cardTemplates=[{"Name": "Card 1", "Front": qfmt, "Back": afmt}], + ) + + +def _term(field: str, value: str) -> str: + """Quote the WHOLE `field:value` term; quotes after the colon match nothing.""" + return '"' + field + ":" + value.replace('"', '\\"') + '"' + + +def _existing_by_front(deck: str, slug: str) -> dict[str, int]: + r"""Map unescaped Front -> note id for every note of this problem. + + Diffing in Python, not via a Front search: Anki search treats `_ * \` + specially and fields are stored HTML-escaped, so a search misses notes. + """ + ids = _call("findNotes", query=f"{_term('deck', deck)} {_term('Problem', slug)}") + infos = _call("notesInfo", notes=ids) if ids else [] + return {html.unescape(n["fields"]["Front"]["value"]): n["noteId"] for n in infos} + + +def _add(deck: str, model: str, slug: str, front: str, back: str) -> None: + """Add one note and make sure it sits in `deck`.""" + note_id = _call( + "addNote", + note={ + "deckName": deck, + "modelName": model, + "fields": {"Front": front, "Back": back, "Problem": slug}, + "tags": ["leetcode", slug], + "options": {"allowDuplicate": False, "duplicateScope": "deck"}, + }, + ) + # With Anki's "add to current deck" preference on, addNote lands in the + # current deck regardless of deckName (seen on 25.09.2). Move explicitly. + _call("changeDeck", cards=_call("findCards", query=f"nid:{note_id}"), deck=deck) + + +def sync_notes( + deck: str, model: str, slug: str, cards: list[dict[str, str]], *, replace: bool +) -> tuple[int, int, int]: + """Add or update one note per card (fields already HTML-escaped). + + With `replace`, notes of this problem whose Front is not in `cards` are + deleted, so a rewritten deck does not keep the cards it dropped. + Returns (added, updated, deleted). + """ + _call("createDeck", deck=deck) + existing = _existing_by_front(deck, slug) + added = updated = 0 + keep: set[int] = set() + for c in cards: + front, back = c["front"], c["back"] + note_id = existing.get(html.unescape(front)) + if note_id is not None: + _call( + "updateNoteFields", + note={"id": note_id, "fields": {"Front": front, "Back": back}}, + ) + keep.add(note_id) + updated += 1 + continue + _add(deck, model, slug, front, back) + added += 1 + stale = [i for i in existing.values() if i not in keep] if replace else [] + if stale: + _call("deleteNotes", notes=stale) + return added, updated, len(stale) + + +def sync() -> None: + """Ask Anki to sync with AnkiWeb.""" + _call("sync") diff --git a/leetcode_coach/leetcode_coach/card_rules.py b/leetcode_coach/leetcode_coach/card_rules.py new file mode 100644 index 0000000..2e6e987 --- /dev/null +++ b/leetcode_coach/leetcode_coach/card_rules.py @@ -0,0 +1,92 @@ +"""The shape rules every /leetcode post-mortem card must pass. + +Short by construction: a back longer than BACK_MAX_WORDS words or a front +longer than FRONT_MAX_WORDS fails the run and names the offender, so the model +has to shorten instead of shipping a paragraph. Shape is gated the same way: a +yes/no or either/or question is a coin flip, two questions on one front split +the recall, and a `{ ... }` back is a placeholder, not an answer. + +Split out of `make_cards.py` (which keeps the delivery side) for the +250-line cap; the rules are unchanged. +""" + +from __future__ import annotations + +import html +import re + +FRONT_MAX_WORDS = 20 +BACK_MAX_WORDS = 8 +MIN_CARDS, MAX_CARDS = 1, 6 +YES_NO = re.compile( + r"(is|are|does|do|did|can|could|should|will|would|has|have|was|were)\b", + re.IGNORECASE, +) +EITHER_OR = re.compile(r"\bor\b", re.IGNORECASE) +PLACEHOLDER = re.compile(r"\{\s*(\.\.\.|…)\s*\}|…") + +Card = dict[str, str] + + +def shape_errors(i: int, front: str, back: str) -> list[str]: + """Coin-flip and placeholder cards: a pass on them proves no recall.""" + errors: list[str] = [] + if front.count("?") > 1: + errors.append(f"card {i}: two questions on one front; split it") + question = re.split(r"[:—]|\.\s", front)[-1].strip() + if question.endswith("?"): + if YES_NO.match(question): + errors.append(f"card {i}: yes/no front; ask for the thing itself") + elif EITHER_OR.search(question): + errors.append(f"card {i}: either/or front; the back is a coin flip") + if PLACEHOLDER.search(back): + errors.append(f"card {i}: placeholder in back; write the real code") + return errors + + +def validate(slug: str, cards: list[Card]) -> list[str]: + """Return every rule violation as a human-readable line (empty = ok).""" + errors: list[str] = [] + if not MIN_CARDS <= len(cards) <= MAX_CARDS: + errors.append(f"{len(cards)} cards; want {MIN_CARDS}-{MAX_CARDS}") + for i, c in enumerate(cards, 1): + front, back = str(c.get("front", "")).strip(), str(c.get("back", "")).strip() + if not front or not back: + errors.append(f"card {i}: empty front or back") + continue + if "\n" in front or "\n" in back: + errors.append(f"card {i}: newlines are not allowed") + if len(front.split()) > FRONT_MAX_WORDS: + errors.append( + f"card {i}: front is {len(front.split())} words (max {FRONT_MAX_WORDS})" + ) + if (n := len(back.split())) > BACK_MAX_WORDS: + errors.append( + f"card {i}: back is {n} words (max {BACK_MAX_WORDS}): {back!r}" + ) + errors += shape_errors(i, front, back) + if not slug: + errors.append("missing slug") + return errors + + +def _cell(text: str) -> str: + """Markdown table cell: a bare pipe would split it in two.""" + return text.strip().replace("|", "\\|") + + +def _esc(text: str) -> str: + """HTML-escape one field (quotes are left alone).""" + return html.escape(text.strip(), quote=False) + + +def table(cards: list[Card]) -> str: + """Markdown table for the chat reply, so the model relays instead of retyping.""" + rows = ["| Front | Back |", "|---|---|"] + rows += [f"| {_cell(c['front'])} | {_cell(c['back'])} |" for c in cards] + return "\n".join(rows) + + +def escaped(cards: list[Card]) -> list[Card]: + """Anki fields are HTML: escape so `a None: + """One line per turn: timing, tries and the three criteria.""" + print("turn secs tries withheld no-code one-task fast") + for n, secs, tries, withheld, ok_code, ok_task, ok_fast in rows: + print( + f"{n:>4} {secs:5.1f} {tries:>5} {withheld!s:>8} {ok_code!s:>7} " + f"{ok_task!s:>8} {ok_fast!s:>5}" + ) + + +def main() -> int: + """Run the six turns, print a per-criterion table, exit 0 only on PASS.""" + statement = subprocess.run( + [sys.executable, str(Path(__file__).with_name("fetch_problem.py")), "two-sum"], + capture_output=True, + text=True, + check=True, + ).stdout + start = time.monotonic() + local_backend.chat([{"role": "user", "content": "Say ok."}]) + print(f"model load + warm-up: {time.monotonic() - start:.1f}s") + coach = local_coach.LocalCoach.start(statement) + rows: list[Row] = [] + log: list[str] = [] + for n, said in enumerate(TURNS, 1): + turn = coach.respond(said) + shown, _ = local_cards.split_cards(turn.text) + leaks = local_coach.gate_findings(shown, statement, coach.user_texts()) + rows.append( + ( + n, + turn.seconds, + turn.attempts, + turn.withheld, + not leaks, + local_prompt.ends_with_one_task(shown), + turn.seconds < MAX_SECONDS, + ) + ) + log.append( + f"## Turn {n}\n\n**kuhy:** {said}\n\n**coach** " + f"({turn.seconds:.1f}s, attempts {turn.attempts}):\n\n{shown}\n" + ) + OUT_DIR.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now(tz=dt.UTC).astimezone() + out = OUT_DIR / f"eval-{stamp:%Y%m%d-%H%M%S}.md" + out.write_text("\n".join(log), encoding="utf-8") + _print_table(rows) + passed = all(all(r[4:]) for r in rows) + print(f"{'PASS' if passed else 'FAIL'} transcript: {out}") + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/leetcode_coach/leetcode_coach/fetch_problem.py b/leetcode_coach/leetcode_coach/fetch_problem.py new file mode 100755 index 0000000..936cb1c --- /dev/null +++ b/leetcode_coach/leetcode_coach/fetch_problem.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Fetch a LeetCode problem statement + starter code as markdown. + +Usage: fetch_problem.py [--lang javascript] + +Uses LeetCode's public GraphQL endpoint (no login). Exit codes: + 0 printed the problem + 1 bad input / network error + 2 problem not available without login (premium) or unknown slug + +Stdlib only and free of package imports on purpose: SKILL.md runs it by path +(`python3 ~/.claude/skills/leetcode/fetch_problem.py`), which works without +any install. Its stdout IS its interface (T201 exemption in pyproject.toml). +""" + +from __future__ import annotations + +import argparse +from html.parser import HTMLParser +import json +import logging +import re +import sys +from typing import Any +import urllib.error +import urllib.request + +# A fixed name, and its own NullHandler: run by path this module is +# `__main__` and the package __init__ (which silences the package logger) +# never loads, so without both the warning below would print a second copy of +# the "fetch failed" line through logging's last-resort handler. +_LOG = logging.getLogger("leetcode_coach.fetch_problem") +_LOG.addHandler(logging.NullHandler()) + +GRAPHQL = "https://leetcode.com/graphql" +QUERY = """ +query q($s: String!) { + question(titleSlug: $s) { + questionFrontendId title difficulty content isPaidOnly + codeSnippets { langSlug code } + topicTags { name } + hints + } +} +""" +EXIT_FAILED, EXIT_UNAVAILABLE = 1, 2 +_OPEN = {"p": "\n", "div": "\n", "ul": "\n", "ol": "\n", "li": "\n- ", "br": "\n"} +_CLOSE = {"p": "\n", "div": "\n"} +_BOTH = {"strong": "**", "b": "**", "em": "_", "i": "_"} + + +class _HtmlToText(HTMLParser): + """Minimal HTML -> markdown-ish text; keeps
/ verbatim."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.out: list[str] = []
+        self.in_pre = False
+        self.in_code = False
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        """Open a fence, an inline code span or a markdown marker."""
+        del attrs  # no attribute matters for the text
+        if tag == "pre":
+            self.in_pre = True
+            self.out.append("\n```\n")
+        elif tag == "code" and not self.in_pre:
+            self.in_code = True
+            self.out.append("`")
+        elif tag in _OPEN or tag in _BOTH:
+            self.out.append(_OPEN.get(tag) or _BOTH[tag])
+
+    def handle_endtag(self, tag: str) -> None:
+        """Close what `handle_starttag` opened."""
+        if tag == "pre":
+            self.in_pre = False
+            self.out.append("\n```\n")
+        elif tag == "code" and self.in_code:
+            self.in_code = False
+            self.out.append("`")
+        elif tag in _CLOSE or tag in _BOTH:
+            self.out.append(_CLOSE.get(tag) or _BOTH[tag])
+
+    def handle_data(self, data: str) -> None:
+        """Text: verbatim inside 
, whitespace-collapsed elsewhere."""
+        self.out.append(data if self.in_pre else re.sub(r"\s+", " ", data))
+
+    def text(self) -> str:
+        """Everything fed so far, with runs of blank lines squeezed."""
+        return re.sub(r"\n{3,}", "\n\n", "".join(self.out)).strip()
+
+
+def slug_from(arg: str) -> str:
+    """Accept a full URL (with /description/, /submissions/, ?query) or a bare slug."""
+    m = re.search(r"leetcode\.(?:com|cn)/problems/([a-z0-9-]+)", arg)
+    if m:
+        return m.group(1)
+    if re.fullmatch(r"[a-z0-9-]+", arg):
+        return arg
+    msg = f"not a LeetCode problem URL or slug: {arg!r}"
+    raise ValueError(msg)
+
+
+def fetch(slug: str) -> dict[str, Any] | None:
+    """The GraphQL `question` object for `slug`, or None when LeetCode has none."""
+    body = json.dumps({"query": QUERY, "variables": {"s": slug}}).encode()
+    req = urllib.request.Request(
+        GRAPHQL,
+        data=body,
+        headers={
+            "Content-Type": "application/json",
+            "Referer": f"https://leetcode.com/problems/{slug}/",
+            "User-Agent": "Mozilla/5.0 (leetcode-coach skill)",
+        },
+    )
+    with urllib.request.urlopen(req, timeout=20) as resp:  # fixed https URL
+        payload = json.load(resp)
+    question: dict[str, Any] | None = payload.get("data", {}).get("question")
+    return question
+
+
+def render(q: dict[str, Any], lang: str) -> str:
+    """Markdown: title, tags, statement, starter code, then the hidden hints."""
+    parser = _HtmlToText()
+    parser.feed(q["content"] or "")
+    tags = ", ".join(t["name"] for t in q["topicTags"]) or "none"
+    snippets = {s["langSlug"]: s["code"] for s in q["codeSnippets"]}
+    chosen = lang if lang in snippets else next(iter(snippets), None)
+    lines = [
+        f"# {q['questionFrontendId']}. {q['title']} ({q['difficulty']})",
+        f"Tags: {tags}",
+        f"Languages available: {', '.join(snippets) or 'none'}",
+        "",
+        parser.text(),
+        "",
+    ]
+    if chosen:
+        lines += [
+            f"## Starter code ({chosen})",
+            "```",
+            snippets[chosen].rstrip(),
+            "```",
+        ]
+    if q["hints"]:
+        lines += ["", "## Site hints (do NOT show to the user unasked)"]
+        lines += [f"- {h}" for h in q["hints"]]
+    return "\n".join(lines)
+
+
+def main() -> int:
+    """Print the problem named on argv; see the module docstring for exit codes."""
+    ap = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
+    ap.add_argument("problem", help="LeetCode problem URL or title slug")
+    ap.add_argument("--lang", default="javascript", help="starter-code language slug")
+    args = ap.parse_args()
+    try:
+        slug = slug_from(args.problem)
+        q = fetch(slug)
+    except (
+        ValueError,
+        urllib.error.URLError,
+        TimeoutError,
+        json.JSONDecodeError,
+    ) as exc:
+        _LOG.warning("fetch failed: %s", exc)
+        print(f"fetch failed: {exc}", file=sys.stderr)
+        return EXIT_FAILED
+    if q is None or q.get("isPaidOnly") or not q.get("content"):
+        print(
+            f"problem {slug!r} is unknown or premium-only (no statement without login)",
+            file=sys.stderr,
+        )
+        return EXIT_UNAVAILABLE
+    print(render(q, args.lang))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/leetcode_coach/leetcode_coach/gate_detector.py b/leetcode_coach/leetcode_coach/gate_detector.py
new file mode 100644
index 0000000..6ddce07
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/gate_detector.py
@@ -0,0 +1,176 @@
+"""Transcript parsing and pasteable-snippet detection for the no-solution gate.
+
+Shared by the Stop hook (`stop_gate.py`) and the offline coach
+(`local_coach.py`), so both paths judge a reply with one detector. A snippet
+is pasteable when a code span or fenced block uses an identifier from the
+problem's starter signature together with a call/spread/assignment -- unless
+that exact snippet already appears in kuhy's own messages or the fetched
+problem (quoting their code back is fine).
+
+Moved verbatim from ``~/.claude/hooks/leetcode_no_solution_stop.py``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any
+
+_LOG = logging.getLogger(__name__)
+
+KEYWORDS = {
+    "function",
+    "return",
+    "const",
+    "let",
+    "var",
+    "self",
+    "def",
+    "class",
+    "this",
+    "new",
+    "true",
+    "false",
+    "null",
+    "None",
+    "int",
+    "str",
+    "list",
+    "number",
+    "string",
+    "void",
+}
+CODE_RE = re.compile(r"```.*?```|`[^`\n]+`", re.DOTALL)
+SIG_RE = re.compile(
+    r"(?:function\s*(\w*)|def\s+(\w+)|(\w+)\s*=\s*(?:function)?)\s*\(([^)]*)\)"
+)
+# User-role messages that are not kuhy: the injected skill body and the hook's
+# own block reason (which quotes the offending snippet and must not whitelist it).
+NOT_KUHY = ("Base directory for this skill", "Stop hook feedback:")
+MIN_IDENT_LEN = 2  # single letters (`t`) are too common in prose and toy code
+LANG_PREFIXES = ("javascript", "js", "python", "typescript")
+
+Entry = dict[str, Any]
+
+
+def _parse(line: str) -> object:
+    """One JSONL line, or None when it is not JSON (e.g. a half-written tail)."""
+    try:
+        return json.loads(line)
+    except json.JSONDecodeError as exc:
+        _LOG.warning("skipping a transcript line that is not JSON: %s", exc)
+        return None
+
+
+def transcript_lines(raw: str) -> list[Entry]:
+    """Every JSON object in a JSONL transcript, in order."""
+    return [d for d in map(_parse, raw.splitlines()) if isinstance(d, dict)]
+
+
+def _content(msg: Entry) -> str | list[Entry]:
+    """The `message.content` field: a string or a list of content blocks."""
+    content: str | list[Entry] = msg.get("message", {}).get("content", "")
+    return content
+
+
+def text_of(msg: Entry) -> str:
+    """All text in an entry, tool results included."""
+    content = _content(msg)
+    if isinstance(content, str):
+        return content
+    parts = []
+    for c in content:
+        if c.get("type") == "text":
+            parts.append(c.get("text", ""))
+        elif c.get("type") == "tool_result":
+            inner = c.get("content", "")
+            parts.append(
+                inner
+                if isinstance(inner, str)
+                else " ".join(x.get("text", "") for x in inner)
+            )
+    return "\n".join(parts)
+
+
+def kuhy_text(msg: Entry) -> str:
+    """Only what kuhy typed: no tool results, no skill body, no echoed hook reason."""
+    content = _content(msg)
+    texts = (
+        [content]
+        if isinstance(content, str)
+        else [c.get("text", "") for c in content if c.get("type") == "text"]
+    )
+    return "\n".join(t for t in texts if not t.startswith(NOT_KUHY))
+
+
+def _signature_names(code: str) -> set[str]:
+    """Function and parameter names of every signature in `code`."""
+    names: set[str] = set()
+    for sig in SIG_RE.finditer(code):
+        names.update(n for n in sig.groups()[:3] if n)
+        for raw in sig.group(4).split(","):
+            param = raw.strip().split(":")[0].split("=")[0].strip().lstrip("*")
+            if param:
+                names.add(param)
+    return names
+
+
+def starter_identifiers(entries: list[Entry]) -> set[str]:
+    """Function name + parameter names from the fetched '## Starter code' block."""
+    for e in entries:
+        if e.get("type") != "user":
+            continue
+        txt = text_of(e)
+        if "## Starter code" not in txt:
+            continue
+        block = txt.split("## Starter code", 1)[1]
+        m = re.search(r"```\n?(.*?)```", block, re.DOTALL)
+        code = m.group(1) if m else block
+        # LeetCode starters carry the test harness in a block comment; drop
+        # comments so its helpers (`log`, `...argsArr`) are not taken as symbols.
+        code = re.sub(r"/\*.*?\*/|//[^\n]*|#[^\n]*", "", code, flags=re.DOTALL)
+        names = _signature_names(code)
+        return {n for n in names if len(n) >= MIN_IDENT_LEN and n not in KEYWORDS}
+    return set()
+
+
+def _squash(text: str) -> str:
+    """Collapse every whitespace run to one space."""
+    return re.sub(r"\s+", " ", text).strip()
+
+
+def allowed_text(entries: list[Entry]) -> str:
+    """Everything the reply may quote: kuhy's messages and the fetched problem."""
+    parts = []
+    for e in entries:
+        if e.get("type") != "user":
+            continue
+        parts.append(kuhy_text(e))
+        content = _content(e)
+        if not isinstance(content, str):
+            parts.extend(
+                text_of({"message": {"content": [c]}})
+                for c in content
+                if c.get("type") == "tool_result"
+            )
+    return _squash("\n".join(parts))
+
+
+def offending_snippets(reply: str, idents: set[str], allowed: str) -> list[str]:
+    """Code in `reply` that uses a starter identifier and is not a quote."""
+    bad = []
+    for snippet in CODE_RE.findall(reply):
+        body = snippet.strip("`").strip()
+        if body.startswith(LANG_PREFIXES):
+            body = body.split("\n", 1)[1] if "\n" in body else ""
+        if not re.search(r"[(=]|\.\.\.", body):
+            continue
+        if not any(re.search(rf"\b{re.escape(i)}\b", body) for i in idents):
+            continue
+        probe = _squash(body).removesuffix("...").strip()
+        # Whole-token match: the harness has `...argsArr`; it must not admit `...args`.
+        if probe and re.search(rf"(? [what you are stuck on...]
+#
+# Symlinked from ~/.local/bin/leetcode. The package lives in leetcode-guard;
+# the repo root goes on PYTHONPATH so the launcher works from any checkout,
+# installed or not, and -P keeps the caller's cwd off sys.path.
+# ============================================================================
+
+set -euo pipefail
+
+repo="$(dirname "$(dirname "$(readlink -f "$0")")")"
+PYTHONPATH="${repo}${PYTHONPATH:+:${PYTHONPATH}}" \
+    exec /usr/bin/python3 -P -m leetcode_coach.leetcode_repl "$@"
diff --git a/leetcode_coach/leetcode_coach/leetcode_repl.py b/leetcode_coach/leetcode_coach/leetcode_repl.py
new file mode 100755
index 0000000..0e17d16
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/leetcode_repl.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Coaching REPL for /leetcode that only ever shows the post-gate reply.
+
+In the interactive TUI a Stop hook runs after the reply has streamed, so a
+blocked reply (one that hands kuhy a pasteable line) is already on screen
+before the rewrite appears. `claude -p` returns nothing until the hooks are
+done, so this loop is the only client where the no-solution gate actually
+withholds text from the human. Each turn is one `claude -p --resume` call.
+
+When Claude answers with its usage-limit message the session continues on the
+local model (`local_session.py`) with the conversation so far; `--local` starts
+there. The signal is the synthetic reply "You've hit your session limit ·
+resets 3pm (Europe/Warsaw)" (error rate_limit, HTTP 429), as recorded in real
+transcripts on 2026-09-26.
+
+Input: a block ends on an empty line (paste code, then press Enter twice).
+`quit` or Ctrl-D leaves. `reveal` is passed through like any other message.
+Started by the `leetcode` launcher as `python3 -m leetcode_coach.leetcode_repl`;
+its prints ARE the session (T201 exemption in pyproject.toml).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+from typing import Any
+
+from leetcode_coach import local_session
+
+# Named, not __name__: under `-m` this module is `__main__`, which would escape
+# the package logger's NullHandler and print the warning to the session.
+_LOG = logging.getLogger("leetcode_coach.leetcode_repl")
+
+# PATH lookup, not a fixed path: claude lives in ~/.local/bin here, and the
+# old /usr/bin/claude default made every REPL turn crash (2026-10-20).
+CLAUDE = os.environ.get("LEETCODE_CLAUDE_BIN") or shutil.which("claude") or "claude"
+LIMIT_RE = re.compile(r"hit your (?:\w+ )?limit|usage limit reached", re.IGNORECASE)
+RATE_LIMIT_STATUS = 429
+
+
+def ask(args: list[str], prompt: str) -> dict[str, Any]:
+    """One `claude -p` turn; the JSON result, or a stand-in carrying stderr."""
+    env = {**os.environ, "CLAUDE_TTY_FIX_OFF": "1"}
+    proc = subprocess.run(
+        [CLAUDE, "-p", *args, "--output-format", "json", prompt],
+        capture_output=True,
+        stdin=subprocess.DEVNULL,  # claude -p would otherwise drain our REPL input
+        text=True,
+        env=env,
+        check=False,
+        cwd=Path.home(),
+    )
+    reply: object
+    try:
+        reply = json.loads(proc.stdout, strict=False) if proc.stdout.strip() else {}
+    except json.JSONDecodeError as exc:
+        _LOG.warning("claude -p printed something that is not JSON: %s", exc)
+        reply = {}
+    result: dict[str, Any] = reply if isinstance(reply, dict) else {}
+    result["_stderr"] = proc.stderr.strip()
+    if proc.returncode != 0 and "result" not in result:
+        result["result"] = f"[claude exited {proc.returncode}]\n{proc.stderr.strip()}"
+    return result
+
+
+def is_limit_hit(reply: dict[str, Any]) -> bool:
+    """Whether Claude refused this turn because the plan's usage limit is spent."""
+    if reply.get("api_error_status") == RATE_LIMIT_STATUS:
+        return True
+    text = f"{reply.get('result', '')}\n{reply.get('_stderr', '')}"
+    return bool(LIMIT_RE.search(text))
+
+
+def read_block() -> str | None:
+    """Lines until an empty one; None on EOF with nothing typed."""
+    print("\nyou> ", end="", flush=True)
+    lines: list[str] = []
+    while True:
+        try:
+            line = input()
+        except EOFError:
+            # Ctrl-D is how kuhy leaves; benign, logged only to satisfy the
+            # repo's no-silent-except rule (the package logger is silent).
+            _LOG.warning("stdin closed (Ctrl-D); ending the block")
+            return "\n".join(lines) if lines else None
+        if not line.strip():
+            if lines:
+                return "\n".join(lines)
+            continue
+        lines.append(line)
+
+
+def run_claude(model: str, problem: list[str]) -> int:
+    """The Claude session; hands over to the local model on a usage limit."""
+    opener = local_session.opening_message(problem[1:])
+    first = ask(["--model", model], "/leetcode " + " ".join(problem))
+    if is_limit_hit(first):
+        print(f"[{first.get('result', 'usage limit')} -- switching to local model]")
+        return local_session.run(problem[0], [], opener, read_block)
+    session = first.get("session_id")
+    print(first.get("result", ""))
+    if not session:
+        return 1
+    history = [
+        {"role": "user", "content": opener},
+        {"role": "assistant", "content": str(first.get("result", ""))},
+    ]
+    while True:
+        block = read_block()
+        if block is None or local_session.is_quit(block):
+            return 0
+        print("\n...", flush=True)
+        reply = ask(["--model", model, "--resume", session], block)
+        if is_limit_hit(reply):
+            print(f"[{reply.get('result', 'usage limit')} -- continuing locally]")
+            return local_session.run(problem[0], history, block, read_block)
+        print(reply.get("result", ""))
+        history += [
+            {"role": "user", "content": block},
+            {"role": "assistant", "content": str(reply.get("result", ""))},
+        ]
+
+
+def main(argv: list[str]) -> int:
+    """Parse `[--local]  [stuck on...]` and run a session."""
+    local = "--local" in argv
+    args = [a for a in argv if a != "--local"]
+    if not args or args[0] in {"-h", "--help"}:
+        print("Usage: leetcode [--local]  [what you are stuck on...]")
+        print("  LEETCODE_MODEL=opus leetcode ...     to use a stronger model")
+        print("  --local   coach on the local Ollama model (free, offline)")
+        return 0
+    if local:
+        opener = local_session.opening_message(args[1:])
+        return local_session.run(args[0], [], opener, read_block)
+    return run_claude(os.environ.get("LEETCODE_MODEL", "sonnet"), args)
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/leetcode_coach/leetcode_coach/local_backend.py b/leetcode_coach/leetcode_coach/local_backend.py
new file mode 100644
index 0000000..807f750
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/local_backend.py
@@ -0,0 +1,151 @@
+"""Ollama transport for the offline /leetcode coach.
+
+One non-streaming `/api/chat` call per reply, so the no-solution gate can judge
+the whole reply before anything reaches the screen -- the same reason the
+Claude path uses `claude -p`. Thinking is switched off (`think: false`) and any
+`` block that leaks anyway is stripped before the gate sees the text.
+
+Ollama silently truncates a prompt longer than `num_ctx` from the front, which
+would drop the system prompt (the coaching rules) without an error. So the
+context is set explicitly and both the client-side estimate and the server's
+`prompt_eval_count` are checked against it.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+import re
+import subprocess
+import time
+from typing import TYPE_CHECKING, Any
+import urllib.request
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+OLLAMA_URL = "http://localhost:11434"
+# The instruct build, not plain `qwen3:30b-a3b`: that tag is the 2507 Thinking
+# build, which ignores `think: false` and `/no_think` and writes ~1k tokens of
+# reasoning into `content` before every reply (measured 2026-10-03: 66 s turns).
+MODEL = "qwen3:30b-a3b-instruct-2507-q4_K_M"
+NUM_CTX = 16384
+KEEP_ALIVE = "5m"  # free the 3090 soon after a session ends
+TEMPERATURE = 0.4
+REQUEST_TIMEOUT_S = 300.0  # the first call also loads ~18 GB into VRAM
+CHARS_PER_TOKEN = 3.0  # pessimistic, so the estimate trips before Ollama truncates
+GPU_HOG_MIB = 2048
+THINK_RE = re.compile(r".*?\s*", re.DOTALL)
+
+
+class ContextOverflowError(RuntimeError):
+    """The conversation no longer fits in `NUM_CTX`; the rules would be cut."""
+
+
+@dataclass(frozen=True)
+class LocalReply:
+    """One model reply, already stripped of thinking."""
+
+    text: str
+    seconds: float
+    prompt_tokens: int
+
+
+def strip_think(text: str) -> str:
+    """Drop `...` blocks, a dangling close and a dangling open."""
+    text = THINK_RE.sub("", text)
+    if "" in text:  # a thinking build opens the block in its template
+        text = text.rsplit("", 1)[1]
+    if "" in text:
+        text = text.split("", 1)[0]
+    return text.strip()
+
+
+def http_json(
+    url: str, payload: dict[str, Any] | None, timeout: float
+) -> dict[str, Any]:
+    """POST `payload` as JSON (GET when None) and decode the JSON answer."""
+    if not url.startswith(OLLAMA_URL + "/"):
+        msg = f"refusing a non-Ollama URL: {url}"
+        raise ValueError(msg)
+    data = None if payload is None else json.dumps(payload).encode()
+    req = urllib.request.Request(
+        url, data=data, headers={"Content-Type": "application/json"}
+    )
+    with urllib.request.urlopen(req, timeout=timeout) as resp:  # localhost only
+        answer: dict[str, Any] = json.loads(resp.read().decode())
+    return answer
+
+
+def estimate_tokens(messages: list[dict[str, str]]) -> int:
+    """Upper-ish bound on prompt tokens, from character count."""
+    return int(sum(len(m["content"]) for m in messages) / CHARS_PER_TOKEN)
+
+
+def chat(
+    messages: list[dict[str, str]],
+    *,
+    post: Callable[[str, dict[str, Any] | None, float], dict[str, Any]] = http_json,
+    clock: Callable[[], float] = time.monotonic,
+) -> LocalReply:
+    """Ask the local model for the next assistant message.
+
+    Raises:
+        ContextOverflowError: when the prompt would not fit in `NUM_CTX`.
+    """
+    if estimate_tokens(messages) >= NUM_CTX:
+        msg = f"conversation too long for num_ctx={NUM_CTX}; start a new session"
+        raise ContextOverflowError(msg)
+    payload = {
+        "model": MODEL,
+        "messages": messages,
+        "stream": False,
+        "think": False,
+        "keep_alive": KEEP_ALIVE,
+        "options": {"num_ctx": NUM_CTX, "temperature": TEMPERATURE},
+    }
+    start = clock()
+    data = post(f"{OLLAMA_URL}/api/chat", payload, REQUEST_TIMEOUT_S)
+    seconds = clock() - start
+    prompt_tokens = int(data.get("prompt_eval_count") or 0)
+    if prompt_tokens >= NUM_CTX:
+        msg = f"Ollama evaluated {prompt_tokens} prompt tokens >= num_ctx={NUM_CTX}"
+        raise ContextOverflowError(msg)
+    text = strip_think(str(data.get("message", {}).get("content") or ""))
+    return LocalReply(text=text, seconds=seconds, prompt_tokens=prompt_tokens)
+
+
+def model_loaded(
+    post: Callable[[str, dict[str, Any] | None, float], dict[str, Any]] = http_json,
+) -> bool:
+    """Whether `MODEL` is already resident (then loading costs nothing new)."""
+    data = post(f"{OLLAMA_URL}/api/ps", None, 5.0)
+    return any(m.get("name") == MODEL for m in data.get("models", []))
+
+
+def gpu_hogs(
+    run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
+) -> list[tuple[str, int]]:
+    """Non-Ollama GPU processes holding more than `GPU_HOG_MIB` (likely a game).
+
+    Loading an 18 GB model next to one would either starve it or spill the
+    model onto the CPU, so the REPL asks first.
+    """
+    proc = run(
+        [
+            "nvidia-smi",
+            "--query-compute-apps=process_name,used_memory",
+            "--format=csv,noheader,nounits",
+        ],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    hogs = []
+    for line in proc.stdout.splitlines():
+        name, _, mib = line.rpartition(",")
+        if not mib.strip().isdigit() or "ollama" in name.lower():
+            continue
+        if int(mib) > GPU_HOG_MIB:
+            hogs.append((name.strip(), int(mib)))
+    return hogs
diff --git a/leetcode_coach/leetcode_coach/local_cards.py b/leetcode_coach/leetcode_coach/local_cards.py
new file mode 100644
index 0000000..ef90157
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/local_cards.py
@@ -0,0 +1,53 @@
+"""Post-mortem cards from a local reply: extract the ```cards block, build the deck.
+
+The local model has no tools, so instead of the Bash call SKILL.md describes it
+emits the JSON in a fenced ```cards block; this module lifts it out of the
+displayed text and pipes it to `make_cards`, whose validation is unchanged.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import subprocess
+import sys
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+_LOG = logging.getLogger(__name__)
+
+CARDS_RE = re.compile(r"```cards\s*\n(.*?)```", re.DOTALL)
+
+
+def split_cards(reply: str) -> tuple[str, dict[str, Any] | None]:
+    """Remove a ```cards block from `reply`; return (display text, parsed JSON)."""
+    match = CARDS_RE.search(reply)
+    if match is None:
+        return reply, None
+    display = (reply[: match.start()] + reply[match.end() :]).strip()
+    try:
+        payload = json.loads(match.group(1))
+    except json.JSONDecodeError as exc:
+        _LOG.warning("the cards block is not JSON: %s", exc)
+        return display, {"invalid": match.group(1)}
+    return display, payload if isinstance(payload, dict) else {"invalid": payload}
+
+
+def deliver_cards(
+    payload: dict[str, Any],
+    run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
+) -> tuple[bool, str]:
+    """Pipe the cards to the card builder; (accepted, its stdout+stderr)."""
+    if "invalid" in payload:
+        return False, f"cards block is not a JSON object: {payload['invalid']!r}"
+    proc = run(
+        [sys.executable, "-m", "leetcode_coach.make_cards", "-"],
+        input=json.dumps(payload),
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    return proc.returncode == 0, (proc.stdout + proc.stderr).strip()
diff --git a/leetcode_coach/leetcode_coach/local_coach.py b/leetcode_coach/leetcode_coach/local_coach.py
new file mode 100644
index 0000000..ec4cae3
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/local_coach.py
@@ -0,0 +1,118 @@
+"""The offline /leetcode coach: SKILL.md rules, the no-solution gate, cards.
+
+Claude Code enforces the gate as a Stop hook; there is no harness here, so the
+same detector (`gate_detector`, which the Stop hook also uses) runs on every
+local reply before it is shown. A blocked reply is sent back with the gate's
+reason, like the hook's `{"decision": "block"}`, and after `MAX_TRIES` failed
+drafts a fixed safe message is shown instead -- unlike the hook, which fails
+open, because a small local model is likelier to keep leaking.
+
+The model has no tools: the statement is prefetched into the system prompt,
+and cards come back as a ```cards block (`local_cards.py`). "End with exactly
+one task" is checked here too, as a soft gate: a reply that fails only that is
+regenerated, and after `MAX_TRIES` the last code-free draft is shown anyway.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from leetcode_coach import gate_detector, local_backend, local_cards, local_prompt
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+SKILL_DIR = Path(__file__).resolve().parent
+MAX_TRIES = 3
+
+
+def gate_findings(reply: str, statement: str, user_texts: list[str]) -> list[str]:
+    """Snippets in `reply` the hook would block, judged on synthetic entries."""
+    if any(t.strip() == "reveal" for t in user_texts):
+        return []
+    entries = [
+        {"type": "user", "message": {"content": text}}
+        for text in [statement, *user_texts]
+    ]
+    idents = gate_detector.starter_identifiers(entries)
+    if not idents:
+        return []
+    allowed = gate_detector.allowed_text(entries)
+    return list(gate_detector.offending_snippets(reply, idents, allowed))
+
+
+@dataclass
+class Turn:
+    """What one user message produced."""
+
+    text: str
+    seconds: float
+    attempts: int
+    withheld: bool
+
+
+@dataclass
+class LocalCoach:
+    """A local coaching conversation, gate applied to every reply."""
+
+    statement: str
+    history: list[dict[str, str]]
+    chat: Callable[[list[dict[str, str]]], local_backend.LocalReply] = (
+        local_backend.chat
+    )
+
+    @classmethod
+    def start(
+        cls, statement: str, history: list[dict[str, str]] | None = None
+    ) -> LocalCoach:
+        """A coach seeded with SKILL.md and optionally a prior (Claude) history."""
+        skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
+        prompt = local_prompt.system_prompt(skill, statement)
+        system = {"role": "system", "content": prompt}
+        return cls(statement=statement, history=[system, *(history or [])])
+
+    def user_texts(self) -> list[str]:
+        """Everything kuhy typed so far."""
+        return [m["content"] for m in self.history if m["role"] == "user"]
+
+    @staticmethod
+    def _with_reminder(history: list[dict[str, str]]) -> list[dict[str, str]]:
+        """`history` with `REMINDER` on its last message (a copy; history is kept)."""
+        last = history[-1]
+        reminded = {**last, "content": last["content"] + local_prompt.REMINDER}
+        return [*history[:-1], reminded]
+
+    def respond(self, text: str) -> Turn:
+        """Append kuhy's message and return the first reply that passes the gate."""
+        self.history.append({"role": "user", "content": text})
+        scratch: list[dict[str, str]] = []
+        total = 0.0
+        last_clean: str | None = None
+        for attempt in range(1, MAX_TRIES + 1):
+            reply = self.chat(self._with_reminder(self.history) + scratch)
+            total += reply.seconds
+            display, payload = local_cards.split_cards(reply.text)
+            bad = gate_findings(display, self.statement, self.user_texts())
+            if not bad:
+                last_clean = reply.text
+                if payload is not None or local_prompt.ends_with_one_task(display):
+                    return self._accept(reply.text, total, attempt)
+                reason = local_prompt.GATE_REASON_SHAPE
+            else:
+                snippets = "; ".join(f"`{b}`" for b in bad)
+                reason = local_prompt.GATE_REASON_CODE.format(snippets=snippets)
+            scratch += [
+                {"role": "assistant", "content": reply.text},
+                {"role": "user", "content": reason},
+            ]
+        if last_clean is not None:  # shape is a soft rule; code is the hard one
+            return self._accept(last_clean, total, MAX_TRIES)
+        self.history.append({"role": "assistant", "content": local_prompt.WITHHELD})
+        return Turn(local_prompt.WITHHELD, total, MAX_TRIES, withheld=True)
+
+    def _accept(self, text: str, seconds: float, attempts: int) -> Turn:
+        """Record `text` as the assistant's reply and wrap it as a Turn."""
+        self.history.append({"role": "assistant", "content": text})
+        return Turn(text, seconds, attempts, withheld=False)
diff --git a/leetcode_coach/leetcode_coach/local_prompt.py b/leetcode_coach/leetcode_coach/local_prompt.py
new file mode 100644
index 0000000..8c9a013
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/local_prompt.py
@@ -0,0 +1,101 @@
+"""What the offline coach tells the local model, and the one-task shape check.
+
+Split out of `local_coach.py` for the 250-line cap; texts are unchanged.
+"""
+
+from __future__ import annotations
+
+import re
+
+WITHHELD = (
+    "(My draft gave away code, so I held it back.) Let's go one step smaller: "
+    "in your own words, what does your current approach do with each number "
+    "as it reads it?"
+)
+REMINDER = (
+    "\n\n[coach reminder, not from kuhy: one hint rung at most; never state "
+    "the key insight, the data structure or the fix; never answer your own "
+    "question; no code with the starter's names; end with exactly one "
+    "question or one instruction.]"
+)
+"""Appended to the newest user message for the model only -- never stored in
+`history` and never seen by the gate. A 3B-active model follows the rules at
+the top of a 3k-token system prompt far less reliably than a line next to the
+question it is answering (measured on the eval: it named the fix in prose)."""
+LOCAL_NOTES = """
+## Local client notes (you are running offline, not in Claude Code)
+
+- You have NO tools. The problem statement and starter code are already below;
+  never say you will fetch, run or test anything.
+- Write plain markdown for a terminal. No emoji.
+- Never write code that uses the starter's function or parameter names unless
+  kuhy has typed `reveal`. A client-side gate rejects such replies.
+- Never name the data structure or algorithm that solves the problem before
+  rung H3 -- not even inside a calibration question. Ask about what kuhy
+  already said instead.
+- The LAST line of every reply is exactly one question or one instruction for
+  kuhy. Nothing after it. ONE question mark in the whole closing paragraph:
+  never stack questions ("What happens? Why?").
+- Never state the key insight, the fix, or what the code "must" do. On a buggy
+  paste, describe what the data does on one concrete input from the examples
+  ("with [3,2,4] and target 6, at i=0 `seen` already holds 3 when the lookup
+  runs") and ask what that lookup returns. kuhy names the fix, not you.
+- When kuhy gives up or begs for the answer, do not just point at `reveal`:
+  drop back one rung and end with a smaller question. You may mention once
+  that typing `reveal` ends the exercise.
+- Never repeat a previous reply. Answer what kuhy just wrote.
+- For the post-mortem cards (section 5b), do NOT print a Bash command. Emit one
+  fenced block tagged `cards` holding the JSON, e.g.
+  ```cards
+  {"slug": "", "cards": [{"front": "JS: ...?", "back": "..."}]}
+  ```
+  The client builds the deck and shows the result.
+
+# The problem (already fetched)
+
+"""
+GATE_REASON_CODE = (
+    "leetcode gate: your reply hands kuhy code they can paste -- {snippets}. "
+    "Rewrite it without that snippet: describe the data or "
+    "name the construct, and keep the one question at the end."
+)
+GATE_REASON_SHAPE = (
+    "leetcode gate: end with exactly ONE question or ONE "
+    "instruction -- your closing paragraph asks more than one "
+    "thing or ends on neither. Rewrite it."
+)
+IMPERATIVE = re.compile(
+    r"^(try|write|predict|explain|tell|describe|trace|run|paste|list|sketch|"
+    r"walk|show|give|say|name|work|think|check|change|fix|look|answer|type|"
+    r"fill|pick|do)\b",
+    re.IGNORECASE,
+)
+
+
+def system_prompt(skill_md: str, statement: str) -> str:
+    """SKILL.md minus frontmatter, fetch step and model gate, plus the problem."""
+    body = skill_md.split("\n---\n", 1)[1] if skill_md.startswith("---") else skill_md
+    body = re.sub(r"## 1\. Fetch the problem.*?(?=\n## 2\.)", "", body, flags=re.DOTALL)
+    body = body.split("\n## Model and client", 1)[0]
+    return body.strip() + "\n" + LOCAL_NOTES + statement.strip() + "\n"
+
+
+def ends_with_one_task(text: str) -> bool:
+    """Closing paragraph asks at most one question and ends on a task.
+
+    The task is the final sentence: a question, or an imperative ("Predict
+    before I tell you."). A question followed by the instruction to answer it
+    is still one task; two question marks are two. Code blocks are not prose,
+    and a quoted question ("has this been seen?") is not a second question.
+    """
+    prose = re.sub(r"```.*?```", "\n\n", text, flags=re.DOTALL)
+    paras = [p.strip() for p in re.split(r"\n\s*\n", prose) if p.strip()]
+    if not paras:
+        return False
+    last = re.sub(r"\"[^\"]*\"|“[^”]*”", "it", paras[-1])
+    last = re.sub(r"[*_>`()]", "", last).strip()
+    if last.count("?") > 1:
+        return False
+    sentences = [x for x in re.split(r"(?<=[.?!])\s+", last) if x.strip()]
+    final = sentences[-1].strip()
+    return final.endswith("?") or bool(IMPERATIVE.match(final))
diff --git a/leetcode_coach/leetcode_coach/local_session.py b/leetcode_coach/leetcode_coach/local_session.py
new file mode 100644
index 0000000..e7a8503
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/local_session.py
@@ -0,0 +1,122 @@
+"""Run a /leetcode coaching loop on the local model.
+
+Entered by `leetcode --local`, or by the REPL mid-session when Claude reports a
+usage limit -- in which case `history` already holds the turns so far, so the
+local model picks up where Claude stopped instead of starting over. Its prints
+ARE the session (T201 exemption in pyproject.toml).
+"""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+import sys
+from typing import TYPE_CHECKING
+import urllib.error
+
+from leetcode_coach import local_backend, local_cards, local_coach
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+_LOG = logging.getLogger(__name__)
+
+CARD_RETRIES = 2
+FETCH_PREMIUM = 2
+
+
+def opening_message(stuck: list[str]) -> str:
+    """The first user message, standing in for `/leetcode  [stuck on]`."""
+    said = " ".join(stuck).strip()
+    return "Start the session. " + (
+        f"What I'm stuck on: {said}" if said else "I have not said what I'm stuck on."
+    )
+
+
+def is_quit(block: str) -> bool:
+    """An explicit request to leave."""
+    return block.strip().lower() in {"quit", "exit"}
+
+
+def fetch_statement(
+    problem: str,
+    read: Callable[[], str | None],
+    runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
+) -> str | None:
+    """Statement + starter code, or whatever kuhy pastes when it cannot be fetched."""
+    proc = runner(
+        [sys.executable, str(local_coach.SKILL_DIR / "fetch_problem.py"), problem],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if proc.returncode == 0:
+        return proc.stdout
+    why = "premium or unknown problem" if proc.returncode == FETCH_PREMIUM else ""
+    print(f"[could not fetch the problem: {why or proc.stderr.strip()}]")
+    print("Paste the statement and starter code, then an empty line:")
+    return read()
+
+
+def confirm_gpu(ask: Callable[[str], str] = input) -> bool:
+    """Ask before loading ~18 GB of VRAM next to another GPU-heavy process."""
+    if local_backend.model_loaded():
+        return True
+    hogs = local_backend.gpu_hogs()
+    if not hogs:
+        return True
+    print("[the GPU is busy -- loading the local model would compete with:]")
+    for name, mib in hogs:
+        print(f"    {name}  {mib} MiB")
+    answer = ask(f"Load {local_backend.MODEL} anyway? [y/N] ")
+    return answer.strip().lower() in {"y", "yes"}
+
+
+def show(turn: local_coach.Turn, coach: local_coach.LocalCoach) -> None:
+    """Print one reply; build its cards deck, feeding rejections back."""
+    for _ in range(CARD_RETRIES + 1):
+        display, payload = local_cards.split_cards(turn.text)
+        print(display)
+        if payload is None:
+            return
+        ok, out = local_cards.deliver_cards(payload)
+        print(out)
+        if ok:
+            return
+        print("\n...", flush=True)
+        turn = coach.respond(
+            f"make_cards.py rejected the cards:\n{out}\n"
+            "Fix the named card and emit the whole ```cards block again."
+        )
+
+
+def run(
+    problem: str,
+    history: list[dict[str, str]],
+    first: str,
+    read: Callable[[], str | None],
+) -> int:
+    """Coach locally until kuhy quits. `first` is sent before reading input."""
+    statement = fetch_statement(problem, read)
+    if not statement:
+        return 1
+    try:
+        if not confirm_gpu():
+            print("[not loading the local model]")
+            return 1
+        coach = local_coach.LocalCoach.start(statement, history)
+        print(f"[local: {local_backend.MODEL} via Ollama]\n\n...", flush=True)
+        show(coach.respond(first), coach)
+        while (block := read()) is not None and not is_quit(block):
+            print("\n...", flush=True)
+            show(coach.respond(block), coach)
+    except (urllib.error.URLError, ConnectionError) as exc:
+        _LOG.warning("Ollama is not reachable: %s", exc)
+        print(f"[Ollama is not reachable at {local_backend.OLLAMA_URL}: {exc}]")
+        print("[start it with: systemctl start ollama]")
+        return 1
+    except local_backend.ContextOverflowError as exc:
+        _LOG.warning("local session ended: %s", exc)
+        print(f"[{exc}]")
+        return 1
+    return 0
diff --git a/leetcode_coach/leetcode_coach/make_cards.py b/leetcode_coach/leetcode_coach/make_cards.py
new file mode 100755
index 0000000..0b0ff0c
--- /dev/null
+++ b/leetcode_coach/leetcode_coach/make_cards.py
@@ -0,0 +1,170 @@
+#!/usr/bin/python3
+"""Turn a /leetcode post-mortem into an Anki deck file (.apkg).
+
+Usage: make_cards.py [--dry-run | --replace]    (- = stdin)
+  {"slug": "timeout-cancellation",
+   "cards": [{"front": "JS: cancel a pending setTimeout?",
+              "back": "clearTimeout(id)"}]}
+
+The shape rules (word caps, coin-flip and placeholder fronts) live in
+`card_rules.py`; a violation fails the run (exit 1) and names the offender.
+Deck and model ids are fixed and the note guid is derived from (slug, front),
+so re-importing a regenerated file updates the same notes instead of
+duplicating them. Fields are HTML, so text is escaped (`a notes are upserted directly and
+     a sync to AnkiWeb is triggered, so AnkiDroid gets them at its next open.
+  2. Otherwise -> ~/data/learn/leetcode/anki/-.apkg, imported by
+     `anki ` (which also starts Anki).
+Either way the cards are appended to ~/data/learn/leetcode/cards.tsv.
+
+Runs on the system python (genanki in its user site, installed by
+install.sh); it used to be a `uv run --script` file. Its printed table and
+delivery line ARE its interface (T201 exemption in pyproject.toml).
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import html
+import json
+from pathlib import Path
+import shlex
+import sys
+from typing import Protocol, cast
+
+import genanki
+
+from leetcode_coach import anki_connect, card_rules
+
+DECK_ID = 1_695_042_117_001  # fixed: every session's file imports into the same deck
+MODEL_ID = 1_695_042_117_002
+DECK_NAME = "LeetCode"
+OUT_DIR = Path.home() / "data" / "learn" / "leetcode"  # ~/data: an allowed root bucket
+FLAGS = ("--dry-run", "--replace")
+
+MODEL_NAME = "LeetCode coach (basic)"
+FIELDS = ["Front", "Back", "Problem"]
+QFMT = "{{Front}}

{{Problem}}" +AFMT = "{{FrontSide}}
{{Back}}" +CSS = ".card { font-family: sans-serif; font-size: 22px; text-align: center; }" + +MODEL = genanki.Model( + MODEL_ID, + MODEL_NAME, + fields=[{"name": f} for f in FIELDS], + templates=[{"name": "Card 1", "qfmt": QFMT, "afmt": AFMT}], + css=CSS, +) + + +class Note(Protocol): + """The part of `genanki.Note` this module relies on (genanki is untyped).""" + + @property + def guid(self) -> str: + """Stable id: re-importing the same (slug, front) updates the note.""" + + @property + def fields(self) -> list[str]: + """Front, Back, Problem -- HTML-escaped text.""" + + @property + def tags(self) -> list[str]: + """Anki tags.""" + + +def card_note(front: str, back: str, slug: str) -> Note: + """A note whose guid ignores escaping, so a regenerated deck updates it.""" + guid = genanki.guid_for(slug, html.unescape(front)) + note = genanki.Note( + model=MODEL, fields=[front, back, slug], tags=["leetcode", slug], guid=guid + ) + return cast("Note", note) + + +def _log(slug: str, cards: list[card_rules.Card], today: str) -> None: + """Append the cards to the plain-text history.""" + OUT_DIR.mkdir(parents=True, exist_ok=True) + with (OUT_DIR / "cards.tsv").open("a", encoding="utf-8") as fh: + for c in cards: + row = (today, slug, c["front"].strip(), c["back"].strip()) + fh.write("\t".join(row) + "\n") + + +def push_direct(slug: str, cards: list[card_rules.Card], *, replace: bool) -> str: + """AnkiConnect path: upsert into the open desktop collection, then sync.""" + anki_connect.ensure_model(MODEL_NAME, FIELDS, QFMT, AFMT, CSS) + added, updated, deleted = anki_connect.sync_notes( + DECK_NAME, MODEL_NAME, slug, card_rules.escaped(cards), replace=replace + ) + anki_connect.sync() + return ( + f"{added} added, {updated} updated, {deleted} deleted in {DECK_NAME!r}; " + "AnkiWeb sync requested (lands on the phone at AnkiDroid's next sync)" + ) + + +def build_apkg(slug: str, cards: list[card_rules.Card], today: str) -> Path: + """Offline path: write a deck file that `anki ` imports.""" + deck = genanki.Deck(DECK_ID, DECK_NAME) + for c in card_rules.escaped(cards): + deck.add_note(card_note(c["front"], c["back"], slug)) + out_dir = OUT_DIR / "anki" + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"{today}-{slug}.apkg" + genanki.Package(deck).write_to_file(str(out)) + return out + + +def _usage() -> str: + """The `Usage:` line of this module's docstring.""" + return (__doc__ or "").strip().splitlines()[2] + + +def main() -> int: + """Validate the cards on argv/stdin, then deliver them; 0 on success.""" + args = sys.argv[1:] + flags = {a for a in args if a in FLAGS} + rest = [a for a in args if a not in FLAGS] + if len(rest) != 1 or len(flags) > 1: + print(_usage(), file=sys.stderr) + return 1 + src = rest[0] + raw = sys.stdin.read() if src == "-" else Path(src).read_text(encoding="utf-8") + spec = json.loads(raw) + slug, cards = str(spec.get("slug", "")).strip(), list(spec.get("cards", [])) + if errors := card_rules.validate(slug, cards): + print("cards rejected:\n " + "\n ".join(errors), file=sys.stderr) + return 1 + if "--dry-run" in flags: + print(card_rules.table(cards)) + return 0 + replace = "--replace" in flags + online = anki_connect.available() + if replace and not online: + print( + "--replace needs Anki open (AnkiConnect); nothing changed", file=sys.stderr + ) + return 1 + today = dt.datetime.now(tz=dt.UTC).date().isoformat() + _log(slug, cards, today) + print(card_rules.table(cards)) + if online: + done = push_direct(slug, cards, replace=replace) + print(f"{len(cards)} cards -> Anki desktop: {done}") + return 0 + out = build_apkg(slug, cards, today) + print(f"{len(cards)} cards -> {out} (Anki desktop not open). Import with:") + print(f"anki {shlex.quote(str(out))}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/leetcode_coach/leetcode_coach/stop_gate.py b/leetcode_coach/leetcode_coach/stop_gate.py new file mode 100644 index 0000000..8a82d1f --- /dev/null +++ b/leetcode_coach/leetcode_coach/stop_gate.py @@ -0,0 +1,104 @@ +"""Stop hook: in a /leetcode session, refuse a reply that hands kuhy a line of code. + +The prose rule ("never write a line for their code") held in one haiku +role-play out of two, so it is adjudicated here instead. A reply is blocked +when any code span or fenced block in it uses an identifier from the +problem's starter signature (e.g. `fn`, `args`) together with a call/spread/ +assignment -- unless that exact snippet already appears in one of kuhy's own +messages (quoting their code back is fine). `reveal` lifts the gate. + +Blocking returns {"decision": "block"} so the model rewrites the reply; the +rewrite is judged again, at most MAX_BLOCKS times per prompt (counted in +~/.claude/state/leetcode/.blocks), then the gate fails open. + +Run as ``/usr/bin/python3 -P -m leetcode_coach.stop_gate``; the file in +``~/.claude/hooks`` is a shim that execs exactly that. The printed JSON line +IS the hook's interface (hence the T201 exemption in pyproject.toml). +""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + +from leetcode_coach import gate_detector + +SKILL_LINK = Path.home() / ".claude" / "skills" / "leetcode" +# A /leetcode slash command, or a model-invoked Skill(leetcode) load (which +# leetcode_url_userprompt.sh triggers when kuhy pastes a bare problem URL). +# The skill directory is a symlink to this package since 2026-10-23, and the +# harness may print either side of it as the base directory: both arm. +MARKERS = ( + ( + '"content":"leetcode' + "\\n/leetcode" + ), + f'"text":"Base directory for this skill: {SKILL_LINK}', + f'"text":"Base directory for this skill: {Path(__file__).resolve().parent}', +) +STATE_DIR = Path.home() / ".claude" / "state" / "leetcode" +MAX_BLOCKS = 2 # rewrites per prompt before the gate lets the reply through + + +def verdict(raw: str, reply: str) -> str | None: + """Reason to block `reply`, or None when it may stand.""" + if not reply or not any(m in raw for m in MARKERS): + return None + entries = gate_detector.transcript_lines(raw) + users = [gate_detector.kuhy_text(e) for e in entries if e.get("type") == "user"] + if any(u.strip() == "reveal" for u in users): + return None + idents = gate_detector.starter_identifiers(entries) + if not idents: + return None + allowed = gate_detector.allowed_text(entries) + bad = gate_detector.offending_snippets(reply, idents, allowed) + if not bad: + return None + return ( + "leetcode gate: your reply hands kuhy code they can paste \u2014 " + + "; ".join(f"`{b}`" for b in bad) + + ". Rewrite it without that snippet: describe the data or name the " + "construct, and keep the one question at the end." + ) + + +def blocks_so_far(session_id: str, prompt_id: str) -> int: + """How many times this prompt's reply was already sent back. + + Bounded so a model that cannot satisfy the gate still gets to answer + (fail-open at the cap). + """ + state = STATE_DIR / f"{session_id}.blocks" + entries = state.read_text(encoding="utf-8").split() if state.is_file() else [] + n = entries.count(prompt_id) + STATE_DIR.mkdir(parents=True, exist_ok=True) + with state.open("a", encoding="utf-8") as fh: + fh.write(prompt_id + "\n") + return n + + +def main() -> int: + """Judge the reply in the Stop event on stdin; print a block decision or nothing.""" + data = json.load(sys.stdin) + path = Path(data.get("transcript_path", "")) + if not path.is_file(): + return 0 + # The transcript on disk still lacks the reply being judged; the harness + # hands it over separately (measured: `last_assistant_message`, 2.1.276). + reply = str(data.get("last_assistant_message") or "") + reason = verdict(path.read_text(encoding="utf-8", errors="replace"), reply) + if not reason: + return 0 + if ( + blocks_so_far(str(data.get("session_id")), str(data.get("prompt_id"))) + >= MAX_BLOCKS + ): + return 0 + print(json.dumps({"decision": "block", "reason": reason})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/leetcode_coach/leetcode_coach/tests/__init__.py b/leetcode_coach/leetcode_coach/tests/__init__.py new file mode 100644 index 0000000..00a82d1 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the /leetcode coach (moved from ~/.claude/skills/leetcode).""" diff --git a/leetcode_coach/leetcode_coach/tests/_shared.py b/leetcode_coach/leetcode_coach/tests/_shared.py new file mode 100644 index 0000000..5fe57a1 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/_shared.py @@ -0,0 +1,44 @@ +"""Fixtures shared by the coach tests: a two-sum statement and canned replies.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from leetcode_coach import local_backend + +STATEMENT = """# 1. Two Sum (Easy) +Return indices of two numbers adding up to target. + +## Starter code (javascript) +``` +var twoSum = function(nums, target) { +}; +``` +""" +LEAK = "Here:\n```js\nfor (const n of nums) seen.set(target - n, n)\n```\nTry it?" +SAFE = "What could you remember about each number you have already seen?" + + +def ollama_reply(text: str, tokens: int = 100) -> dict[str, Any]: + """An Ollama /api/chat answer body.""" + return {"message": {"content": text}, "prompt_eval_count": tokens} + + +def completed( + code: int, out: str = "", err: str = "" +) -> subprocess.CompletedProcess[str]: + """A finished subprocess with the given exit code and output.""" + return subprocess.CompletedProcess([], code, out, err) + + +class FakeChat: + """Returns scripted replies and records every prompt it was sent.""" + + def __init__(self, *texts: str) -> None: + self.texts = list(texts) + self.prompts: list[list[dict[str, str]]] = [] + + def __call__(self, messages: list[dict[str, str]]) -> local_backend.LocalReply: + self.prompts.append(list(messages)) + return local_backend.LocalReply(self.texts.pop(0), 1.0, 10) diff --git a/leetcode_coach/leetcode_coach/tests/test_anki_connect.py b/leetcode_coach/leetcode_coach/tests/test_anki_connect.py new file mode 100644 index 0000000..feec7ab --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_anki_connect.py @@ -0,0 +1,152 @@ +"""Tests for the AnkiConnect side of the /leetcode card builder.""" + +from __future__ import annotations + +import io +import json +from typing import Any, Self +import urllib.error +import urllib.request + +import pytest + +from leetcode_coach import anki_connect, card_rules + + +def _card(front: str, back: str = "x") -> dict[str, str]: + return {"front": front, "back": back} + + +class FakeAnki: + """Minimal in-memory AnkiConnect: notes keyed by id.""" + + def __init__(self, notes: dict[int, tuple[str, str]]) -> None: + self.notes = {i: {"Front": f, "Back": b} for i, (f, b) in notes.items()} + self.deleted: list[int] = [] + self.next_id = 100 + + def __call__(self, action: str, **p: Any) -> Any: + if action == "findNotes": + return list(self.notes) + if action == "notesInfo": + return [ + { + "noteId": i, + "fields": {k: {"value": v} for k, v in self.notes[i].items()}, + } + for i in p["notes"] + ] + if action == "updateNoteFields": + self.notes[p["note"]["id"]].update(p["note"]["fields"]) + elif action == "addNote": + self.next_id += 1 + self.notes[self.next_id] = dict(p["note"]["fields"]) + return self.next_id + elif action == "deleteNotes": + self.deleted += p["notes"] + for i in p["notes"]: + del self.notes[i] + elif action == "findCards": + return [1] + return None + + +def test_sync_notes_replace_keeps_matching_front_and_deletes_the_rest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = FakeAnki({1: ("n & m?", "old"), 2: ("dropped?", "gone")}) + monkeypatch.setattr(anki_connect, "_call", fake) + cards = card_rules.escaped([_card("n & m?", "new"), _card("added?", "a")]) + result = anki_connect.sync_notes("D", "M", "s", cards, replace=True) + assert result == (1, 1, 1) + assert fake.deleted == [2] + assert fake.notes[1] == {"Front": "n & m?", "Back": "new"} + + +def test_sync_notes_without_replace_deletes_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = FakeAnki({2: ("dropped?", "gone")}) + monkeypatch.setattr(anki_connect, "_call", fake) + assert anki_connect.sync_notes("D", "M", "s", [_card("a?")], replace=False) == ( + 1, + 0, + 0, + ) + assert fake.deleted == [] + + +def test_sync_notes_on_an_empty_problem_skips_notes_info( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = FakeAnki({}) + monkeypatch.setattr(anki_connect, "_call", fake) + assert anki_connect.sync_notes("D", "M", "s", [_card("a?")], replace=True) == ( + 1, + 0, + 0, + ) + + +class FakeResponse(io.BytesIO): + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +def _serve( + monkeypatch: pytest.MonkeyPatch, replies: dict[str, Any] +) -> list[dict[str, Any]]: + sent: list[dict[str, Any]] = [] + + def urlopen(req: Any, timeout: int) -> FakeResponse: + body = json.loads(req.data) + sent.append(body) + reply = replies[body["action"]] + if isinstance(reply, Exception): + raise reply + return FakeResponse(json.dumps(reply).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + return sent + + +def test_call_available_ensure_model_and_sync(monkeypatch: pytest.MonkeyPatch) -> None: + sent = _serve( + monkeypatch, + { + "version": {"result": 6, "error": None}, + "modelNames": {"result": ["Basic"], "error": None}, + "createModel": {"result": {}, "error": None}, + "sync": {"result": None, "error": None}, + }, + ) + assert anki_connect.available() is True + anki_connect.ensure_model("M", ["F"], "q", "a", "css") + anki_connect.sync() + assert [b["action"] for b in sent] == [ + "version", + "modelNames", + "createModel", + "sync", + ] + + +def test_ensure_model_skips_existing_and_errors_surface( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sent = _serve( + monkeypatch, + { + "modelNames": {"result": ["M"], "error": None}, + "deleteNotes": {"result": None, "error": "boom"}, + "version": urllib.error.URLError("refused"), + }, + ) + anki_connect.ensure_model("M", ["F"], "q", "a", "css") + assert [b["action"] for b in sent] == ["modelNames"] + with pytest.raises(anki_connect.AnkiConnectError, match="deleteNotes: boom"): + anki_connect._call("deleteNotes", notes=[1]) + assert anki_connect.available() is False diff --git a/leetcode_coach/leetcode_coach/tests/test_card_rules.py b/leetcode_coach/leetcode_coach/tests/test_card_rules.py new file mode 100644 index 0000000..3b57f9f --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_card_rules.py @@ -0,0 +1,66 @@ +"""Tests for the card shape rules the /leetcode builder enforces.""" + +from __future__ import annotations + +import pytest + +from leetcode_coach import card_rules + + +def _card(front: str, back: str = "x") -> dict[str, str]: + return {"front": front, "back": back} + + +@pytest.mark.parametrize( + ("front", "back", "error"), + [ + ("JS: nest or separate loops?", "Nest.", "either/or"), + ("JS: setTimeout — built in, or do you write them?", "built in", "either/or"), + ("JS: does clearTimeout stop a running fn?", "No", "yes/no"), + ("JS: code after setTimeout — has cb run yet?", "No", "yes/no"), + ("TS: Why does it break? What's the fix?", "closure", "two questions"), + ("TS: return a function?", "return function(p) { ... }", "placeholder"), + ("TS: return a function?", "return f …", "placeholder"), + ], +) +def test_shape_rejects_coin_flips_and_placeholders( + front: str, back: str, error: str +) -> None: + assert any(error in e for e in card_rules.validate("s", [_card(front, back)])) + + +@pytest.mark.parametrize( + ("front", "back"), + [ + ("JS: What does XOR (^) return for different bits vs same?", "1 if different"), + ("JS: write a regex matching every 'cat' or 'dog' in a string.", "/cat|dog/g"), + ("TS: a loop runs while(a || b) — which nodes get skipped?", "leaves"), + ("JS: call fn with the array as separate arguments?", "fn(...args)"), + ("TS: one cur pointer walks one path. To visit all, add what?", "a stack"), + ], +) +def test_shape_accepts_real_questions(front: str, back: str) -> None: + assert card_rules.validate("s", [_card(front, back)]) == [] + + +def test_validate_counts_lengths_and_slug() -> None: + errors = card_rules.validate( + "", [_card("q?", "one two three four five six seven eight nine")] + ) + assert any("back is 9 words" in e for e in errors) + assert "missing slug" in errors + assert card_rules.validate("s", []) == ["0 cards; want 1-6"] + assert card_rules.validate("s", [_card("", "")]) == ["card 1: empty front or back"] + long_front = " ".join(["w"] * 21) + "?" + assert any( + "front is 21 words" in e for e in card_rules.validate("s", [_card(long_front)]) + ) + assert any("newlines" in e for e in card_rules.validate("s", [_card("a\nb?")])) + + +def test_table_escapes_pipes_and_fields_escape_html() -> None: + cards = [_card("JS: a list[list[str]]: + fetched: list[list[str]] = [] + + def run(cmd: list[str], **_k: Any) -> subprocess.CompletedProcess[str]: + fetched.append(cmd) + return completed(0, STATEMENT) + + real_start = local_coach.LocalCoach.start + + def start(text: str, history: Any = None) -> local_coach.LocalCoach: + coach = real_start(text, history) + coach.chat = FakeChat(*texts) + return coach + + monkeypatch.setattr(subprocess, "run", run) + monkeypatch.setattr(local_backend, "chat", lambda _m: None) + monkeypatch.setattr(local_coach.LocalCoach, "start", start) + monkeypatch.setattr(eval_local, "OUT_DIR", tmp_path / "out") + return fetched + + +def test_six_clean_turns_pass( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + fetched = _wire(monkeypatch, tmp_path, *[SAFE] * len(eval_local.TURNS)) + assert eval_local.main() == 0 + assert fetched[0][1].endswith("leetcode_coach/fetch_problem.py") + assert fetched[0][2] == "two-sum" + lines = capsys.readouterr().out.splitlines() + assert lines[0].startswith("model load + warm-up: ") + assert lines[1] == "turn secs tries withheld no-code one-task fast" + assert lines[2] == " 1 1.0 1 False True True True" + assert lines[-1].startswith("PASS transcript: ") + transcript = Path(lines[-1].split("transcript: ", 1)[1]) + assert transcript.parent == tmp_path / "out" + assert "**kuhy:** just give me the code" in transcript.read_text(encoding="utf-8") + + +def test_a_turn_without_a_task_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + taskless = "Nice work." + texts = [taskless] * 3 + [LEAK] * 3 + [SAFE] * (len(eval_local.TURNS) - 2) + _wire(monkeypatch, tmp_path, *texts) + assert eval_local.main() == 1 + lines = capsys.readouterr().out.splitlines() + assert lines[2] == " 1 3.0 3 False True False True" + assert lines[3] == " 2 3.0 3 True True True True" + assert lines[-1].startswith("FAIL transcript: ") diff --git a/leetcode_coach/leetcode_coach/tests/test_fetch_problem.py b/leetcode_coach/leetcode_coach/tests/test_fetch_problem.py new file mode 100644 index 0000000..146988c --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_fetch_problem.py @@ -0,0 +1,155 @@ +"""Tests for the LeetCode statement fetcher (public GraphQL, no login).""" + +from __future__ import annotations + +import io +import json +import runpy +from typing import Any, Self +import urllib.error +import urllib.request + +import pytest + +from leetcode_coach import fetch_problem + +QUESTION = { + "questionFrontendId": "1", + "title": "Two Sum", + "difficulty": "Easy", + "content": ( + "

Given nums, return indices " + "exactly once.

  • one

" + "
x
Input: [2,7]\n  Output: [0,1]
bi" + ), + "isPaidOnly": False, + "codeSnippets": [ + {"langSlug": "python3", "code": "class Solution:\n pass\n"}, + {"langSlug": "javascript", "code": "var twoSum = function(nums) {};"}, + ], + "topicTags": [{"name": "Array"}, {"name": "Hash Table"}], + "hints": ["Use a map."], +} + + +@pytest.mark.parametrize( + ("arg", "slug"), + [ + ("https://leetcode.com/problems/two-sum/description/?x=1", "two-sum"), + ("leetcode.cn/problems/add-two-numbers/submissions/1/", "add-two-numbers"), + ("two-sum", "two-sum"), + ], +) +def test_slug_from_urls_and_bare_slugs(arg: str, slug: str) -> None: + assert fetch_problem.slug_from(arg) == slug + + +def test_slug_from_rejects_anything_else() -> None: + with pytest.raises(ValueError, match="not a LeetCode problem URL or slug"): + fetch_problem.slug_from("Two Sum!") + + +def test_render_markdown_with_starter_and_hidden_hints() -> None: + out = fetch_problem.render(QUESTION, "javascript") + assert out.startswith("# 1. Two Sum (Easy)\nTags: Array, Hash Table\n") + assert "Languages available: python3, javascript" in out + assert "Given `nums`, return **indices** _exactly_ once." in out + assert "\n- one" in out + assert "```\nInput: [2,7]\n Output: [0,1]\n```" in out + assert "**b**_i_" in out + assert "## Starter code (javascript)\n```\nvar twoSum" in out + assert out.endswith("## Site hints (do NOT show to the user unasked)\n- Use a map.") + + +def test_render_falls_back_to_the_first_language_and_no_extras() -> None: + bare = {**QUESTION, "content": None, "topicTags": [], "hints": []} + out = fetch_problem.render(bare, "rust") + assert "Tags: none" in out + assert "## Starter code (python3)" in out + assert "Site hints" not in out + empty = {**bare, "codeSnippets": []} + out = fetch_problem.render(empty, "rust") + assert "Languages available: none" in out + assert "Starter code" not in out + + +class FakeResponse(io.BytesIO): + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +def test_fetch_posts_the_query(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[Any] = [] + + def urlopen(req: Any, timeout: int) -> FakeResponse: + seen.append((req.full_url, json.loads(req.data), req.get_header("Referer"))) + return FakeResponse(json.dumps({"data": {"question": QUESTION}}).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + assert fetch_problem.fetch("two-sum") == QUESTION + url, body, referer = seen[0] + assert url == "https://leetcode.com/graphql" + assert body["variables"] == {"s": "two-sum"} + assert referer == "https://leetcode.com/problems/two-sum/" + + +def _main(monkeypatch: pytest.MonkeyPatch, argv: list[str], fetched: Any) -> int: + def fetch(_slug: str) -> Any: + if isinstance(fetched, Exception): + raise fetched + return fetched + + monkeypatch.setattr("sys.argv", ["fetch_problem.py", *argv]) + monkeypatch.setattr(fetch_problem, "fetch", fetch) + return fetch_problem.main() + + +def test_main_prints_the_problem( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert _main(monkeypatch, ["two-sum", "--lang", "python3"], QUESTION) == 0 + out = capsys.readouterr().out + assert out.startswith("# 1. Two Sum (Easy)\n") + assert "## Starter code (python3)" in out + + +@pytest.mark.parametrize( + ("fetched", "code", "err"), + [ + (urllib.error.URLError("offline"), 1, "fetch failed: "), + (None, 2, "problem 'two-sum' is unknown or premium-only"), + ({**QUESTION, "isPaidOnly": True}, 2, "premium-only"), + ({**QUESTION, "content": ""}, 2, "premium-only"), + ], +) +def test_main_failures_exit_with_a_reason( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + fetched: Any, + code: int, + err: str, +) -> None: + assert _main(monkeypatch, ["two-sum"], fetched) == code + captured = capsys.readouterr() + assert captured.out == "" + assert err in captured.err + + +def test_main_rejects_a_bad_argument( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert _main(monkeypatch, ["Not A Slug"], QUESTION) == 1 + assert capsys.readouterr().err.startswith("fetch failed: not a LeetCode problem") + + +def test_script_entry_point( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("sys.argv", ["fetch_problem.py", "Bad Slug"]) + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(fetch_problem.__file__, run_name="__main__") + assert exit_info.value.code == 1 + assert "fetch failed" in capsys.readouterr().err diff --git a/leetcode_coach/leetcode_coach/tests/test_gate_detector.py b/leetcode_coach/leetcode_coach/tests/test_gate_detector.py new file mode 100644 index 0000000..1569be2 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_gate_detector.py @@ -0,0 +1,97 @@ +"""Tests for the transcript parsing and snippet detection behind the Stop gate.""" + +from __future__ import annotations + +from typing import Any + +from leetcode_coach import gate_detector as det + + +def _user(content: object) -> dict[str, Any]: + return {"type": "user", "message": {"content": content}} + + +def test_transcript_lines_skips_garbage_and_non_objects() -> None: + raw = '{"a": 1}\nnot json\n[1, 2]\n{"b": 2}\n' + assert det.transcript_lines(raw) == [{"a": 1}, {"b": 2}] + + +def test_text_of_joins_text_and_tool_results() -> None: + msg = _user( + [ + {"type": "text", "text": "hi"}, + {"type": "tool_result", "content": "plain"}, + {"type": "tool_result", "content": [{"text": "a"}, {"text": "b"}]}, + {"type": "image"}, + ] + ) + assert det.text_of(msg) == "hi\nplain\na b" + assert det.text_of(_user("s")) == "s" + assert det.text_of({}) == "" + + +def test_kuhy_text_drops_skill_body_hook_echo_and_tool_results() -> None: + msg = _user( + [ + {"type": "text", "text": "mine"}, + {"type": "text", "text": "Base directory for this skill: /x"}, + {"type": "text", "text": "Stop hook feedback: `fn(...args)`"}, + {"type": "tool_result", "content": "fetched"}, + ] + ) + assert det.kuhy_text(msg) == "mine" + assert det.kuhy_text(_user("typed")) == "typed" + + +def test_starter_identifiers_from_js_and_python_starters() -> None: + js = _user( + "## Starter code (javascript)\n```\n/**\n * @param {number[]} nums\n" + " * log(...argsArr)\n */\nvar twoSum = function(nums, target) {\n};\n```" + ) + assert det.starter_identifiers([js]) == {"twoSum", "nums", "target"} + py = _user( + "## Starter code (python3)\n```\nclass Solution:\n" + " def maxArea(self, height: List[int], *rest, k=3) -> int:\n```" + ) + assert det.starter_identifiers([py]) == {"maxArea", "height", "rest"} + unfenced = _user("## Starter code\nfunction sortPeople(names, heights) {") + assert det.starter_identifiers([unfenced]) == {"sortPeople", "names", "heights"} + no_params = _user("## Starter code\n```\nfunction makeCounter() {}\n```") + assert det.starter_identifiers([no_params]) == {"makeCounter"} + + +def test_starter_identifiers_ignores_other_entries() -> None: + assistant = {"type": "assistant", "message": {"content": "## Starter code\nf(ab)"}} + assert det.starter_identifiers([assistant, _user("no starter")]) == set() + assert det.starter_identifiers([_user("## Starter code\n```\nfn = (a)\n```")]) == { + "fn" + } + + +def test_allowed_text_covers_kuhy_and_fetched_problem() -> None: + entries = [ + _user([{"type": "text", "text": "my code\n x"}]), + _user([{"type": "tool_result", "content": "statement"}]), + {"type": "assistant", "message": {"content": "never"}}, + _user("plain"), + ] + assert det.allowed_text(entries) == "my code x statement plain" + + +def test_offending_snippets_rules() -> None: + idents = {"nums", "args"} + reply = ( + "`nums.push(1)` and `nums` and `x = 1` and\n" + "```js\nconst s = new Set(nums)\n```\n" + "```javascript```\n" + "`fn(...args)` vs `fn(...argsArr)` and `" + "nums[0] = " + "1" * 90 + "`" + ) + bad = det.offending_snippets(reply, idents, "fn(...argsArr)") + assert bad == [ + "nums.push(1)", + "const s = new Set(nums)", + "fn(...args)", + ("nums[0] = " + "1" * 90)[:80], + ] + assert det.offending_snippets("`nums.push(1)`", idents, "x nums.push(1) y") == [] + assert det.offending_snippets("`f(...)`", {"f"}, "") == ["f(...)"] diff --git a/leetcode_coach/leetcode_coach/tests/test_leetcode_repl.py b/leetcode_coach/leetcode_coach/tests/test_leetcode_repl.py new file mode 100644 index 0000000..3734974 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_leetcode_repl.py @@ -0,0 +1,174 @@ +"""Tests for the print-mode /leetcode REPL and its hand-over to the local model.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from typing import Any + +import pytest + +from leetcode_coach import leetcode_repl, local_session + +LAUNCHER = Path(leetcode_repl.__file__).with_name("leetcode") + + +@pytest.mark.parametrize( + ("reply", "hit"), + [ + ( + {"result": "You've hit your session limit · resets 3pm (Europe/Warsaw)"}, + True, + ), + ({"result": "You\u2019ve hit your weekly limit · resets Mon"}, True), + ({"result": "", "api_error_status": 429}, True), + ({"result": "x", "_stderr": "Claude AI usage limit reached|1790427600"}, True), + ({"result": "Does your loop hit every element?"}, False), + ], +) +def test_is_limit_hit(reply: dict[str, Any], *, hit: bool) -> None: + assert leetcode_repl.is_limit_hit(reply) is hit + + +def _fake_run(handed: dict[str, Any]) -> Any: + def fake_run( + problem: str, history: list[dict[str, str]], first: str, _r: object + ) -> int: + handed.update(problem=problem, history=history, first=first) + return 0 + + return fake_run + + +def test_limit_mid_session_falls_back_with_history( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + replies = iter( + [ + {"result": "Restated. What feels unclear?", "session_id": "s1"}, + {"result": "You've hit your session limit · resets 8pm (Europe/Warsaw)"}, + ] + ) + monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: next(replies)) + monkeypatch.setattr(leetcode_repl, "read_block", lambda: "the hash map part") + handed: dict[str, Any] = {} + monkeypatch.setattr(local_session, "run", _fake_run(handed)) + assert leetcode_repl.main(["two-sum", "maps"]) == 0 + assert handed["problem"] == "two-sum" + assert handed["first"] == "the hash map part" + assert handed["history"][1]["content"] == "Restated. What feels unclear?" + assert capsys.readouterr().out == ( + "Restated. What feels unclear?\n\n...\n" + "[You've hit your session limit · resets 8pm (Europe/Warsaw) " + "-- continuing locally]\n" + ) + + +def test_limit_on_the_first_turn_starts_locally( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: {"api_error_status": 429}) + handed: dict[str, Any] = {} + monkeypatch.setattr(local_session, "run", _fake_run(handed)) + assert leetcode_repl.run_claude("sonnet", ["two-sum"]) == 0 + assert handed["history"] == [] + assert "not said" in handed["first"] + assert capsys.readouterr().out == "[usage limit -- switching to local model]\n" + + +def test_session_loop_until_quit_and_missing_session( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + sent: list[tuple[list[str], str]] = [] + replies = iter([{"result": "Open?", "session_id": "s1"}, {"result": "Next?"}]) + + def ask(args: list[str], prompt: str) -> dict[str, Any]: + sent.append((args, prompt)) + return next(replies) + + blocks = iter(["my try", "quit"]) + monkeypatch.setattr(leetcode_repl, "ask", ask) + monkeypatch.setattr(leetcode_repl, "read_block", lambda: next(blocks)) + assert leetcode_repl.run_claude("sonnet", ["two-sum", "maps"]) == 0 + assert sent == [ + (["--model", "sonnet"], "/leetcode two-sum maps"), + (["--model", "sonnet", "--resume", "s1"], "my try"), + ] + assert capsys.readouterr().out == "Open?\n\n...\nNext?\n" + monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: {"result": "no id"}) + assert leetcode_repl.run_claude("sonnet", ["two-sum"]) == 1 + + +def test_read_block_joins_lines_until_a_blank( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lines = iter(["", "a", "b", ""]) + monkeypatch.setattr("builtins.input", lambda: next(lines)) + assert leetcode_repl.read_block() == "a\nb" + assert capsys.readouterr().out == "\nyou> " + + def eof() -> str: + raise EOFError + + monkeypatch.setattr("builtins.input", eof) + assert leetcode_repl.read_block() is None + typed = iter(["x"]) + + def then_eof() -> str: + try: + return next(typed) + except StopIteration: + raise EOFError from None + + monkeypatch.setattr("builtins.input", then_eof) + assert leetcode_repl.read_block() == "x" + + +def test_ask_parses_a_failing_claude_stub( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stub = tmp_path / "claude" + body = json.dumps({"is_error": True, "result": "You've hit your session limit"}) + (tmp_path / "out.json").write_text(body) + stub.write_text(f"#!/bin/sh\ncat {tmp_path / 'out.json'}\necho boom >&2\nexit 1\n") + stub.chmod(0o755) + monkeypatch.setattr(leetcode_repl, "CLAUDE", str(stub)) + reply = leetcode_repl.ask([], "hi") + assert leetcode_repl.is_limit_hit(reply) + assert reply["_stderr"] == "boom" + stub.write_text("#!/bin/sh\necho not-json\nexit 3\n") + assert leetcode_repl.ask([], "hi")["result"].startswith("[claude exited 3]") + stub.write_text("#!/bin/sh\necho '[1]'\n") + assert leetcode_repl.ask([], "hi") == {"_stderr": ""} + + +def test_repl_defaults_to_sonnet(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + monkeypatch.delenv("LEETCODE_MODEL", raising=False) + monkeypatch.setattr(leetcode_repl, "run_claude", lambda m, _a: seen.append(m)) + leetcode_repl.main(["two-sum"]) + assert seen == ["sonnet"] + + +def test_local_flag_skips_claude(monkeypatch: pytest.MonkeyPatch) -> None: + handed: dict[str, Any] = {} + monkeypatch.setattr(local_session, "run", _fake_run(handed)) + assert leetcode_repl.main(["--local", "two-sum", "loops"]) == 0 + assert handed["problem"] == "two-sum" + assert handed["first"].endswith("stuck on: loops") + + +def test_help_prints_usage(capsys: pytest.CaptureFixture[str]) -> None: + assert leetcode_repl.main([]) == 0 + assert capsys.readouterr().out.startswith( + "Usage: leetcode [--local] [what you are stuck on...]\n" + ) + + +def test_launcher_runs_the_package_repl() -> None: + proc = subprocess.run( + [str(LAUNCHER), "--help"], capture_output=True, text=True, check=False + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.startswith("Usage: leetcode [--local]") diff --git a/leetcode_coach/leetcode_coach/tests/test_link_script.py b/leetcode_coach/leetcode_coach/tests/test_link_script.py new file mode 100644 index 0000000..42d59b9 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_link_script.py @@ -0,0 +1,90 @@ +"""Tests for scripts/link_leetcode_coach.sh, the install step for the coach. + +Run against a throwaway HOME: the script must never be pointed at the real +~/.claude from a test. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "scripts" / "link_leetcode_coach.sh" +COACH = REPO / "leetcode_coach" + + +def _run(home: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(SCRIPT)], + env={"HOME": str(home), "PATH": "/usr/bin:/bin"}, + capture_output=True, + text=True, + check=False, + ) + + +def _old_skill(home: Path) -> Path: + old = home / ".claude" / "skills" / "leetcode" + old.mkdir(parents=True) + (old / "SKILL.md").write_text("---\nname: leetcode\n---\n", encoding="utf-8") + (old / "leetcode_repl.py").write_text("", encoding="utf-8") + return old + + +def test_fresh_home_gets_both_links(tmp_path: Path) -> None: + proc = _run(tmp_path) + assert proc.returncode == 0, proc.stderr + skill = tmp_path / ".claude" / "skills" / "leetcode" + assert skill.is_symlink() + assert skill.resolve() == COACH + assert (tmp_path / ".local" / "bin" / "leetcode").resolve() == COACH / "leetcode" + assert _run(tmp_path).returncode == 0 # idempotent + + +def test_old_skill_dir_is_backed_up_outside_skills(tmp_path: Path) -> None: + _old_skill(tmp_path) + bin_link = tmp_path / ".local" / "bin" / "leetcode" + bin_link.parent.mkdir(parents=True) + bin_link.symlink_to(tmp_path / ".claude" / "skills" / "leetcode" / "leetcode") + proc = _run(tmp_path) + assert proc.returncode == 0, proc.stderr + backups = list((tmp_path / ".claude" / "backups").glob("skill-leetcode-*")) + assert len(backups) == 1 + assert (backups[0] / "leetcode_repl.py").is_file() + assert sorted(p.name for p in (tmp_path / ".claude" / "skills").iterdir()) == [ + "leetcode" + ] + assert (tmp_path / ".claude" / "skills" / "leetcode").resolve() == COACH + assert bin_link.resolve() == COACH / "leetcode" + + +def test_existing_symlink_is_repointed(tmp_path: Path) -> None: + skills = tmp_path / ".claude" / "skills" + skills.mkdir(parents=True) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (skills / "leetcode").symlink_to(elsewhere) + assert _run(tmp_path).returncode == 0 + assert (skills / "leetcode").resolve() == COACH + assert list(elsewhere.iterdir()) == [] + + +def test_unknown_directory_is_refused(tmp_path: Path) -> None: + other = tmp_path / ".claude" / "skills" / "leetcode" + other.mkdir(parents=True) + (other / "notes.txt").write_text("mine", encoding="utf-8") + proc = _run(tmp_path) + assert proc.returncode == 1 + assert "not replacing it" in proc.stderr + assert (other / "notes.txt").is_file() + assert not other.is_symlink() + + +def test_regular_file_at_the_command_is_refused(tmp_path: Path) -> None: + bin_file = tmp_path / ".local" / "bin" / "leetcode" + bin_file.parent.mkdir(parents=True) + bin_file.write_text("#!/bin/sh\n", encoding="utf-8") + proc = _run(tmp_path) + assert proc.returncode == 1 + assert not bin_file.is_symlink() diff --git a/leetcode_coach/leetcode_coach/tests/test_local_backend.py b/leetcode_coach/leetcode_coach/tests/test_local_backend.py new file mode 100644 index 0000000..4385945 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_local_backend.py @@ -0,0 +1,97 @@ +"""Tests for the Ollama transport of the offline /leetcode coach.""" + +from __future__ import annotations + +import io +import json +from typing import Any, Self +import urllib.request + +import pytest + +from leetcode_coach import local_backend +from leetcode_coach.tests._shared import completed, ollama_reply + + +def test_strip_think_removes_closed_and_dangling_blocks() -> None: + assert local_backend.strip_think("x\nHi") == "Hi" + assert local_backend.strip_think("Hi never closed") == "Hi" + assert local_backend.strip_think("reasoning...\n\n\nAnswer") == "Answer" + + +def test_chat_sends_explicit_ctx_no_think_and_keep_alive() -> None: + sent: list[dict[str, Any]] = [] + + def post(url: str, payload: dict[str, Any] | None, _t: float) -> dict[str, Any]: + assert url.endswith("/api/chat") + assert payload is not None + sent.append(payload) + return ollama_reply("planAnswer") + + reply = local_backend.chat([{"role": "user", "content": "q"}], post=post) + assert reply.text == "Answer" + assert reply.prompt_tokens == 100 + assert sent[0]["think"] is False + assert sent[0]["options"]["num_ctx"] == local_backend.NUM_CTX + assert sent[0]["keep_alive"] == local_backend.KEEP_ALIVE + assert sent[0]["stream"] is False + + +def test_chat_tolerates_an_empty_answer() -> None: + reply = local_backend.chat( + [{"role": "user", "content": "q"}], post=lambda *_a: {}, clock=lambda: 1.0 + ) + assert (reply.text, reply.seconds, reply.prompt_tokens) == ("", 0.0, 0) + + +def test_chat_refuses_a_prompt_ollama_would_truncate() -> None: + def post(*_a: object) -> dict[str, Any]: + return ollama_reply("x", tokens=local_backend.NUM_CTX) + + with pytest.raises(local_backend.ContextOverflowError): + local_backend.chat([{"role": "user", "content": "q"}], post=post) + huge = [{"role": "user", "content": "x" * local_backend.NUM_CTX * 4}] + with pytest.raises(local_backend.ContextOverflowError): + local_backend.chat(huge, post=post) + + +def test_gpu_hogs_ignores_ollama_and_small_processes() -> None: + out = "/usr/bin/ollama, 18000\n/game/bin/witcher3.exe, 6000\nXorg, 300\nbad\n" + hogs = local_backend.gpu_hogs(run=lambda *_a, **_k: completed(0, out)) + assert hogs == [("/game/bin/witcher3.exe", 6000)] + + +def test_model_loaded_reads_api_ps() -> None: + def post(url: str, payload: object, _t: float) -> dict[str, Any]: + assert url.endswith("/api/ps") + assert payload is None + return {"models": [{"name": local_backend.MODEL}]} + + assert local_backend.model_loaded(post) + assert not local_backend.model_loaded(lambda *_a: {}) + + +class FakeResponse(io.BytesIO): + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +def test_http_json_posts_and_gets_only_to_ollama( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[Any] = [] + + def urlopen(req: Any, timeout: float) -> FakeResponse: + seen.append((req.get_method(), req.data, timeout)) + return FakeResponse(json.dumps({"ok": True}).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + url = f"{local_backend.OLLAMA_URL}/api/chat" + assert local_backend.http_json(url, {"a": 1}, 2.0) == {"ok": True} + assert local_backend.http_json(url, None, 3.0) == {"ok": True} + assert seen == [("POST", b'{"a": 1}', 2.0), ("GET", None, 3.0)] + with pytest.raises(ValueError, match="non-Ollama URL"): + local_backend.http_json("http://example.com/api/chat", None, 1.0) diff --git a/leetcode_coach/leetcode_coach/tests/test_local_coach.py b/leetcode_coach/leetcode_coach/tests/test_local_coach.py new file mode 100644 index 0000000..13a1b08 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_local_coach.py @@ -0,0 +1,166 @@ +"""Tests for the offline /leetcode coach: prompt, no-solution gate, cards. + +No network and no GPU: HTTP, nvidia-smi and subprocesses are stubbed. +""" + +from __future__ import annotations + +import json +import sys +from typing import TYPE_CHECKING, Any + +import pytest + +from leetcode_coach import local_cards, local_coach, local_prompt, local_session +from leetcode_coach.tests._shared import LEAK, SAFE, STATEMENT, FakeChat, completed + +if TYPE_CHECKING: + import subprocess + + +def _coach(*texts: str) -> tuple[local_coach.LocalCoach, FakeChat]: + fake = FakeChat(*texts) + coach = local_coach.LocalCoach.start(STATEMENT) + coach.chat = fake + return coach, fake + + +def test_system_prompt_drops_fetch_step_and_model_gate() -> None: + skill = (local_coach.SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") + prompt = local_prompt.system_prompt(skill, STATEMENT) + assert "fetch_problem.py" not in prompt + assert "/model sonnet" not in prompt + assert "## 3. The hint ladder" in prompt + assert prompt.rstrip().endswith("```") + assert "var twoSum" in prompt + + +def test_system_prompt_without_frontmatter_keeps_the_body() -> None: + prompt = local_prompt.system_prompt("# body only", "stmt") + assert prompt.startswith("# body only\n") + assert prompt.endswith("stmt\n") + + +def test_gate_blocks_starter_identifiers_but_allows_quoting_kuhy() -> None: + assert local_coach.gate_findings(LEAK, STATEMENT, ["hi"]) + paste = "for (const n of nums) seen.set(target - n, n)" + assert not local_coach.gate_findings(LEAK, STATEMENT, [paste]) + assert not local_coach.gate_findings(LEAK, STATEMENT, ["reveal"]) + assert not local_coach.gate_findings(LEAK, "no starter here", ["hi"]) + + +def test_leaky_reply_is_regenerated_with_the_gate_reason() -> None: + coach, fake = _coach(LEAK, SAFE) + turn = coach.respond("just give me the code") + assert turn.text == SAFE + assert turn.attempts == 2 + assert "leetcode gate" in fake.prompts[1][-1]["content"] + assert coach.history[-1] == {"role": "assistant", "content": SAFE} + assert all("leetcode gate" not in m["content"] for m in coach.history) + + +def test_three_leaks_withhold_the_reply() -> None: + coach, _ = _coach(LEAK, LEAK, LEAK) + turn = coach.respond("code pls") + assert turn.withheld + assert turn.text == local_prompt.WITHHELD + assert "nums" not in turn.text + + +def test_prior_history_is_kept_for_a_mid_session_fallback() -> None: + prior = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}] + coach = local_coach.LocalCoach.start(STATEMENT, prior) + assert coach.history[1:] == prior + + +def test_split_cards_extracts_json_and_flags_garbage() -> None: + text = 'Done.\n```cards\n{"slug": "two-sum", "cards": []}\n```\n' + assert local_cards.split_cards(text) == ("Done.", {"slug": "two-sum", "cards": []}) + assert local_cards.split_cards("plain") == ("plain", None) + _, bad = local_cards.split_cards("```cards\nnot json\n```") + assert bad is not None + assert "invalid" in bad + _, arr = local_cards.split_cards("```cards\n[1]\n```") + assert arr == {"invalid": [1]} + + +def test_deliver_cards_pipes_to_make_cards() -> None: + seen: dict[str, Any] = {} + + def run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: + seen.update(cmd=cmd, input=kw["input"]) + return completed(0, "| table |") + + ok, out = local_cards.deliver_cards({"slug": "s", "cards": []}, run=run) + assert ok + assert out == "| table |" + assert seen["cmd"] == [sys.executable, "-m", "leetcode_coach.make_cards", "-"] + assert json.loads(seen["input"]) == {"slug": "s", "cards": []} + assert local_cards.deliver_cards({"invalid": "x"})[0] is False + + +def test_rejected_cards_are_fed_back_once_fixed( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + cards = '```cards\n{"slug": "s", "cards": []}\n```' + coach, _ = _coach("fixed\n" + cards) + results = iter([(False, "cards rejected: back too long"), (True, "| ok |")]) + monkeypatch.setattr(local_cards, "deliver_cards", lambda _p: next(results)) + local_session.show(local_coach.Turn("bye\n" + cards, 1.0, 1, withheld=False), coach) + out = capsys.readouterr().out + assert out == "bye\ncards rejected: back too long\n\n...\nfixed\n| ok |\n" + assert "rejected" in coach.user_texts()[-1] + + +def test_rejected_cards_give_up_after_the_retries( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + cards = '```cards\n{"slug": "s", "cards": []}\n```' + # The last failed delivery still asks for a fix that is never shown: that + # is how the skill behaved before the move, kept as-is. + coach, fake = _coach("again\n" + cards, "still\n" + cards, "last\n" + cards) + monkeypatch.setattr(local_cards, "deliver_cards", lambda _p: (False, "no")) + local_session.show(local_coach.Turn("bye\n" + cards, 1.0, 1, withheld=False), coach) + out = capsys.readouterr().out + assert out.count("no\n") == local_session.CARD_RETRIES + 1 + assert "last" not in out + assert fake.texts == [] + + +def test_stacked_questions_are_regenerated_then_shown_if_stuck() -> None: + coach, fake = _coach("What? Why?", SAFE) + turn = coach.respond("hint") + assert (turn.text, turn.attempts) == (SAFE, 2) + assert "exactly ONE question" in fake.prompts[1][-1]["content"] + coach, _ = _coach("What? Why?", "How? When?", "Who? Where?") + turn = coach.respond("hint") + assert not turn.withheld + assert turn.text == "Who? Where?" + + +def test_a_cards_reply_needs_no_closing_question() -> None: + reply = 'Done.\n```cards\n{"slug": "s", "cards": []}\n```' + coach, _ = _coach(reply) + assert coach.respond("bye").text == reply + + +def test_reminder_reaches_the_model_but_not_history() -> None: + coach, fake = _coach(SAFE) + coach.respond("hint") + assert fake.prompts[0][-1]["content"].endswith(local_prompt.REMINDER) + assert coach.history[-2] == {"role": "user", "content": "hint"} + + +@pytest.mark.parametrize( + ("text", "ok"), + [ + ('What lets you check "has this been seen?" fast?', True), + ("Try:\n```js\nx?.y\n```\nWhat prints? Predict before I tell you.", True), + ("Answer in one word.", True), + ("What happens? Why?", False), + ("Nice work.", False), + ("", False), + ], +) +def test_eval_one_task_checker(text: str, *, ok: bool) -> None: + assert local_prompt.ends_with_one_task(text) is ok diff --git a/leetcode_coach/leetcode_coach/tests/test_local_session.py b/leetcode_coach/leetcode_coach/tests/test_local_session.py new file mode 100644 index 0000000..95b946c --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_local_session.py @@ -0,0 +1,142 @@ +"""Tests for the offline /leetcode session loop: fetch, GPU check, run.""" + +from __future__ import annotations + +import sys +from typing import Any +import urllib.error + +import pytest + +from leetcode_coach import local_backend, local_coach, local_session +from leetcode_coach.tests._shared import SAFE, STATEMENT, FakeChat, completed + + +def test_opening_message_and_quit() -> None: + assert local_session.opening_message(["loops"]) == ( + "Start the session. What I'm stuck on: loops" + ) + assert "not said" in local_session.opening_message([]) + assert local_session.is_quit(" Quit ") + assert local_session.is_quit("exit") + assert not local_session.is_quit("quitting is hard") + + +def test_fetch_failure_asks_for_a_paste(capsys: pytest.CaptureFixture[str]) -> None: + got = local_session.fetch_statement( + "x", lambda: "pasted", runner=lambda *_a, **_k: completed(2) + ) + assert got == "pasted" + assert capsys.readouterr().out == ( + "[could not fetch the problem: premium or unknown problem]\n" + "Paste the statement and starter code, then an empty line:\n" + ) + local_session.fetch_statement( + "x", lambda: None, runner=lambda *_a, **_k: completed(1, err=" offline \n") + ) + assert capsys.readouterr().out.startswith("[could not fetch the problem: offline]") + ok = local_session.fetch_statement( + "x", lambda: None, runner=lambda *_a, **_k: completed(0, STATEMENT) + ) + assert ok == STATEMENT + + +def test_fetch_runs_the_fetcher_by_path() -> None: + seen: list[list[str]] = [] + + def run(cmd: list[str], **_k: Any) -> Any: + seen.append(cmd) + return completed(0, "s") + + local_session.fetch_statement("two-sum", lambda: None, runner=run) + assert seen[0][0] == sys.executable + assert seen[0][1].endswith("leetcode_coach/fetch_problem.py") + assert seen[0][2] == "two-sum" + + +def test_confirm_gpu_asks_only_when_something_else_holds_vram( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(local_backend, "model_loaded", lambda: False) + monkeypatch.setattr(local_backend, "gpu_hogs", lambda: [("game", 9000)]) + assert local_session.confirm_gpu(lambda _q: "y") + assert capsys.readouterr().out == ( + "[the GPU is busy -- loading the local model would compete with:]\n" + " game 9000 MiB\n" + ) + assert not local_session.confirm_gpu(lambda _q: "") + monkeypatch.setattr(local_backend, "gpu_hogs", list) + assert local_session.confirm_gpu(lambda _q: pytest.fail("asked")) + monkeypatch.setattr(local_backend, "model_loaded", lambda: True) + assert local_session.confirm_gpu(lambda _q: pytest.fail("asked")) + + +def _session( + monkeypatch: pytest.MonkeyPatch, + *, + statement: str | None = STATEMENT, + gpu_ok: bool = True, + chat: Any = None, +) -> None: + monkeypatch.setattr(local_session, "fetch_statement", lambda _p, _r: statement) + monkeypatch.setattr(local_session, "confirm_gpu", lambda: gpu_ok) + if chat is not None: + real_start = local_coach.LocalCoach.start + + def start( + text: str, history: list[dict[str, str]] | None = None + ) -> local_coach.LocalCoach: + coach = real_start(text, history) + coach.chat = chat + return coach + + monkeypatch.setattr(local_coach.LocalCoach, "start", start) + + +def test_run_coaches_until_quit( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + fake = FakeChat(SAFE, SAFE) + _session(monkeypatch, chat=fake) + blocks = iter(["hint", "quit"]) + assert local_session.run("two-sum", [], "Start.", lambda: next(blocks)) == 0 + out = capsys.readouterr().out + assert out.startswith(f"[local: {local_backend.MODEL} via Ollama]\n\n...\n") + assert out.count(SAFE) == 2 + assert fake.texts == [] + + +def test_run_stops_without_a_statement_or_gpu( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _session(monkeypatch, statement=None) + assert local_session.run("x", [], "Start.", lambda: None) == 1 + _session(monkeypatch, gpu_ok=False) + assert local_session.run("x", [], "Start.", lambda: None) == 1 + assert capsys.readouterr().out == "[not loading the local model]\n" + + +def test_run_reports_an_unreachable_ollama( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def down(_m: object) -> object: + msg = "refused" + raise urllib.error.URLError(msg) + + _session(monkeypatch, chat=down) + assert local_session.run("x", [], "Start.", lambda: None) == 1 + out = capsys.readouterr().out + assert "[Ollama is not reachable at http://localhost:11434: " in out + assert out.endswith("[start it with: systemctl start ollama]\n") + + +def test_run_reports_a_context_overflow( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def full(_m: object) -> object: + msg = "too long" + raise local_backend.ContextOverflowError(msg) + + _session(monkeypatch, chat=full) + assert local_session.run("x", [], "Start.", lambda: None) == 1 + assert capsys.readouterr().out.endswith("[too long]\n") diff --git a/leetcode_coach/leetcode_coach/tests/test_make_cards.py b/leetcode_coach/leetcode_coach/tests/test_make_cards.py new file mode 100644 index 0000000..51df9cb --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_make_cards.py @@ -0,0 +1,126 @@ +"""Tests for the /leetcode card builder CLI: delivery paths and its output.""" + +from __future__ import annotations + +import io +import json +import runpy +from typing import Any + +import pytest + +from leetcode_coach import anki_connect, make_cards + + +def _card(front: str, back: str = "x") -> dict[str, str]: + return {"front": front, "back": back} + + +def test_guid_ignores_escaping() -> None: + raw = make_cards.card_note("n & m?", "b", "slug") + esc = make_cards.card_note("n & m?", "b", "slug") + assert raw.guid == esc.guid + assert raw.fields == ["n & m?", "b", "slug"] + assert raw.tags == ["leetcode", "slug"] + + +def _run(monkeypatch: pytest.MonkeyPatch, argv: list[str], spec: dict[str, Any]) -> int: + monkeypatch.setattr("sys.argv", ["make_cards.py", *argv]) + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(spec))) + return make_cards.main() + + +GOOD = { + "slug": "s", + "cards": [_card("JS: cancel a pending setTimeout?", "clearTimeout(id)")], +} + + +def test_dry_run_prints_without_logging( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + monkeypatch.setattr(make_cards, "OUT_DIR", tmp_path) + assert _run(monkeypatch, ["--dry-run", "-"], GOOD) == 0 + assert capsys.readouterr().out == ( + "| Front | Back |\n|---|---|\n" + "| JS: cancel a pending setTimeout? | clearTimeout(id) |\n" + ) + assert not (tmp_path / "cards.tsv").exists() + + +def test_reads_a_file_argument( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + spec = tmp_path / "cards.json" + spec.write_text(json.dumps(GOOD), encoding="utf-8") + assert _run(monkeypatch, ["--dry-run", str(spec)], {}) == 0 + assert "clearTimeout(id)" in capsys.readouterr().out + + +def test_replace_offline_fails_closed( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + monkeypatch.setattr(make_cards, "OUT_DIR", tmp_path) + monkeypatch.setattr(anki_connect, "available", lambda: False) + assert _run(monkeypatch, ["--replace", "-"], GOOD) == 1 + assert "needs Anki open" in capsys.readouterr().err + assert not (tmp_path / "cards.tsv").exists() + + +def test_offline_writes_apkg_and_prints_bare_command( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + monkeypatch.setattr(make_cards, "OUT_DIR", tmp_path) + monkeypatch.setattr(anki_connect, "available", lambda: False) + assert _run(monkeypatch, ["-"], GOOD) == 0 + lines = capsys.readouterr().out.strip().splitlines() + assert lines[-1].startswith("anki ") + assert lines[-1].endswith(".apkg") + assert "(Anki desktop not open). Import with:" in lines[-2] + assert list((tmp_path / "anki").glob("*-s.apkg")) + tsv = (tmp_path / "cards.tsv").read_text(encoding="utf-8") + assert tsv.endswith("\ts\tJS: cancel a pending setTimeout?\tclearTimeout(id)\n") + + +def test_bad_arguments_and_rejected_cards( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert _run(monkeypatch, ["--dry-run", "--replace", "-"], GOOD) == 1 + usage = ( + "Usage: make_cards.py [--dry-run | --replace] (- = stdin)" + ) + assert capsys.readouterr().err.strip() == usage + assert _run(monkeypatch, [], GOOD) == 1 + assert capsys.readouterr().err.strip() == usage + bad = {"slug": "s", "cards": [_card("JS: nest or separate?", "Nest.")]} + assert _run(monkeypatch, ["--dry-run", "-"], bad) == 1 + err = capsys.readouterr().err + assert err.startswith("cards rejected:\n card 1: either/or front") + + +def test_online_path_pushes_and_reports( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + calls: list[str] = [] + monkeypatch.setattr(make_cards, "OUT_DIR", tmp_path) + monkeypatch.setattr(anki_connect, "available", lambda: True) + monkeypatch.setattr(anki_connect, "ensure_model", lambda *a: calls.append("model")) + monkeypatch.setattr(anki_connect, "sync", lambda: calls.append("sync")) + monkeypatch.setattr( + anki_connect, "sync_notes", lambda *a, replace: (1, 0, 2 if replace else 0) + ) + assert _run(monkeypatch, ["--replace", "-"], GOOD) == 0 + out = capsys.readouterr().out + assert "1 cards -> Anki desktop: 1 added, 0 updated, 2 deleted in 'LeetCode'" in out + assert "AnkiWeb sync requested" in out + assert calls == ["model", "sync"] + + +def test_script_entry_point( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("sys.argv", ["make_cards.py"]) + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(make_cards.__file__, run_name="__main__") + assert exit_info.value.code == 1 + assert capsys.readouterr().err.startswith("Usage: make_cards.py") diff --git a/leetcode_coach/leetcode_coach/tests/test_stop_gate.py b/leetcode_coach/leetcode_coach/tests/test_stop_gate.py new file mode 100644 index 0000000..bb1603c --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_stop_gate.py @@ -0,0 +1,148 @@ +"""Tests for the /leetcode no-solution Stop hook (moved from ~/.claude/hooks).""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +import subprocess +import sys +from typing import Any + +import pytest + +from leetcode_coach import stop_gate + +REPO = Path(stop_gate.__file__).resolve().parents[1] +SHIM = REPO / "leetcode_coach" / "hook_shims" / "leetcode_no_solution_stop.py" +SLASH_LOAD = { + "type": "user", + "message": { + "content": "leetcode\n" + "/leetcode" + }, +} +STARTER = { + "type": "user", + "message": {"content": "## Starter code\nfunction sortPeople(names, heights) {"}, +} +SOLUTION = ( + "Fix: `return names.map((n, i) => [n, heights[i]]).sort((a, b) => b[1] - a[1]);`" +) + + +def _skill_load(base: str) -> dict[str, Any]: + text = f"Base directory for this skill: {base}\n\n# /leetcode" + return {"type": "user", "message": {"content": [{"type": "text", "text": text}]}} + + +def _raw(*entries: dict[str, Any]) -> str: + return "".join(json.dumps(e, separators=(",", ":")) + "\n" for e in entries) + + +@pytest.mark.parametrize( + "load", + [ + SLASH_LOAD, + _skill_load(str(Path.home() / ".claude" / "skills" / "leetcode")), + _skill_load(str(Path(stop_gate.__file__).resolve().parent)), + ], +) +def test_gate_arms_on_every_load_kind(load: dict[str, Any]) -> None: + reason = stop_gate.verdict(_raw(load, STARTER), SOLUTION) + assert reason is not None + assert reason.startswith("leetcode gate: your reply hands kuhy code they can paste") + assert "`return names.map((n, i) => [n, heights[i]]).sort((a, b) =>" in reason + + +def test_gate_idle_without_load_reply_reveal_or_starter() -> None: + assert stop_gate.verdict(_raw(STARTER), SOLUTION) is None + assert stop_gate.verdict(_raw(SLASH_LOAD, STARTER), "") is None + reveal = {"type": "user", "message": {"content": "reveal"}} + assert stop_gate.verdict(_raw(SLASH_LOAD, STARTER, reveal), SOLUTION) is None + assert stop_gate.verdict(_raw(SLASH_LOAD), SOLUTION) is None + assert stop_gate.verdict(_raw(SLASH_LOAD, STARTER), "What is `names`?") is None + + +def test_blocks_are_counted_per_prompt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(stop_gate, "STATE_DIR", tmp_path / "state") + assert [stop_gate.blocks_so_far("s", "p1") for _ in range(3)] == [0, 1, 2] + assert stop_gate.blocks_so_far("s", "p2") == 0 + assert (tmp_path / "state" / "s.blocks").read_text(encoding="utf-8") == ( + "p1\np1\np1\np2\n" + ) + + +def _main( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + event: dict[str, Any], +) -> str: + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(event))) + assert stop_gate.main() == 0 + return capsys.readouterr().out + + +def test_main_blocks_then_fails_open_at_the_cap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(stop_gate, "STATE_DIR", tmp_path / "state") + transcript = tmp_path / "t.jsonl" + transcript.write_text(_raw(SLASH_LOAD, STARTER), encoding="utf-8") + event = { + "transcript_path": str(transcript), + "last_assistant_message": SOLUTION, + "session_id": "s", + "prompt_id": "p", + } + outs = [_main(monkeypatch, capsys, event) for _ in range(stop_gate.MAX_BLOCKS + 1)] + first = json.loads(outs[0]) + assert first["decision"] == "block" + assert first["reason"] == stop_gate.verdict(transcript.read_text(), SOLUTION) + assert outs[1] == outs[0] + assert outs[-1] == "" + + +def test_main_passes_without_transcript_or_verdict( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + assert _main(monkeypatch, capsys, {"transcript_path": str(tmp_path / "no")}) == "" + assert _main(monkeypatch, capsys, {}) == "" + transcript = tmp_path / "t.jsonl" + transcript.write_text(_raw(STARTER), encoding="utf-8") + event = {"transcript_path": str(transcript), "last_assistant_message": SOLUTION} + assert _main(monkeypatch, capsys, event) == "" + + +def test_hook_shim_execs_the_package_gate(tmp_path: Path) -> None: + """The ~/.claude/hooks shim hands stdin to `-m leetcode_coach.stop_gate`.""" + transcript = tmp_path / "t.jsonl" + transcript.write_text(_raw(SLASH_LOAD, STARTER), encoding="utf-8") + event = { + "transcript_path": str(transcript), + "last_assistant_message": SOLUTION, + "session_id": f"shim-test-{tmp_path.name}", + "prompt_id": "p", + } + assert len(SHIM.read_text(encoding="utf-8").splitlines()) <= 3 + proc = subprocess.run( + [sys.executable, str(SHIM)], + input=json.dumps(event), + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + # -P in the shim keeps cwd off sys.path; the real hook finds the + # package through install.sh's editable install, this test through + # PYTHONPATH (which -P still honours). HOME keeps the block counter + # out of the real ~/.claude/state. + env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin", "PYTHONPATH": str(REPO)}, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["decision"] == "block"