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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 15:06:40 +02:00
commit b730cffaf4
31 changed files with 3470 additions and 0 deletions

View File

@ -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 <url> [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: "<leetcode URL or slug> [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 "<first token>"
```
- 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": "<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 '<path>'` 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 <url>` (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 <url>` 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).

View File

@ -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())

View File

@ -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")

View File

@ -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<b` or `&` survive rendering."""
return [{"front": _esc(c["front"]), "back": _esc(c["back"])} for c in cards]

View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Scripted acceptance run of the local coach on two-sum (no human needed).
Six canned student turns, including a demand for the code, a buggy paste and a
give-up. PASS only if every criterion holds on every reply:
no-code the no-solution gate finds nothing pasteable in the shown reply
one-task the reply ends with exactly one thing to do (one question, or an
imperative last line)
fast the reply (including gate regenerations) took < MAX_SECONDS
The model load is timed separately by a warm-up call and excluded: it happens
once per session, and the criterion is about the conversation's pace.
Writes the transcript to ~/data/claude-scratch/leetcode-local/. Its printed
table IS the verdict (T201 exemption in pyproject.toml).
python3 -m leetcode_coach.eval_local
"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
import subprocess
import sys
import time
from leetcode_coach import local_backend, local_cards, local_coach, local_prompt
from leetcode_coach.local_session import opening_message
MAX_SECONDS = 15.0
OUT_DIR = Path.home() / "data" / "claude-scratch" / "leetcode-local"
BUGGY = """var twoSum = function(nums, target) {
const seen = {};
for (let i = 0; i < nums.length; i++) {
seen[nums[i]] = i;
if (seen[target - nums[i]] !== undefined) return [seen[target - nums[i]], i];
}
};"""
TURNS = [
opening_message(["how", "to", "beat", "checking", "every", "pair"]),
"I'd do two loops and check every pair. That's slow though, right?",
"just give me the code",
"hint",
BUGGY,
"I give up, what would you write?",
]
Row = tuple[int, float, int, bool, bool, bool, bool]
def _print_table(rows: list[Row]) -> 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())

View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Fetch a LeetCode problem statement + starter code as markdown.
Usage: fetch_problem.py <url-or-slug> [--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 <pre>/<code> 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 <pre>, 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())

View File

@ -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"(?<!\w){re.escape(probe)}(?!\w)", allowed):
continue # quoting kuhy's code or the starter back at them
bad.append(_squash(body)[:80])
return bad

View File

@ -0,0 +1,3 @@
"""Shim: the /leetcode Stop gate lives in ~/src/leetcode-guard (leetcode_coach.stop_gate)."""
import os
if __name__ == "__main__": os.execv("/usr/bin/python3", ["/usr/bin/python3", "-P", "-m", "leetcode_coach.stop_gate"])

View File

@ -0,0 +1,18 @@
#!/bin/bash
# ============================================================================
# leetcode — coaching session in print mode, on the cheapest model.
# Print mode matters: the no-solution Stop gate can only withhold a reply
# from a client that waits for hooks, and the interactive TUI streams first.
# leetcode <url-or-slug> [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 "$@"

View File

@ -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] <url-or-slug> [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] <url-or-slug> [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:]))

View File

@ -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
`<think>` 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"<think>.*?</think>\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 `<think>...</think>` blocks, a dangling close and a dangling open."""
text = THINK_RE.sub("", text)
if "</think>" in text: # a thinking build opens the block in its template
text = text.rsplit("</think>", 1)[1]
if "<think>" in text:
text = text.split("<think>", 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

View File

@ -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()

View File

@ -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)

View File

@ -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": "<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))

View File

@ -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 <url> [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

View File

@ -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] <cards.json | -> (- = 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<b` would vanish).
--dry-run validate and print the table; no log, no delivery
--replace make this the problem's whole card set: notes of the slug that
are not in the input get deleted. Needs Anki open (AnkiConnect);
an .apkg import cannot delete, so without it the run fails.
Delivery, in order of preference:
1. Anki desktop is open with AnkiConnect -> 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/<date>-<slug>.apkg, imported by
`anki <file>` (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}}<br><br><span style='font-size:70%;color:#888'>{{Problem}}</span>"
AFMT = "{{FrontSide}}<hr id=answer>{{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 <file>` 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())

View File

@ -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/<session>.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":"<command-message>leetcode</command-message>'
"\\n<command-name>/leetcode</command-name>"
),
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())

View File

@ -0,0 +1 @@
"""Tests for the /leetcode coach (moved from ~/.claude/skills/leetcode)."""

View File

@ -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)

View File

@ -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 &amp; 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

View File

@ -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<b and x|y?", "n & m")]
assert "| JS: a<b and x\\|y? | n & m |" in card_rules.table(cards)
assert card_rules.escaped(cards) == [
{"front": "JS: a&lt;b and x|y?", "back": "n &amp; m"}
]

View File

@ -0,0 +1,70 @@
"""Tests for the scripted acceptance run of the local coach (no Ollama needed)."""
from __future__ import annotations
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING, Any
from leetcode_coach import eval_local, local_backend, local_coach
from leetcode_coach.tests._shared import LEAK, SAFE, STATEMENT, FakeChat, completed
if TYPE_CHECKING:
import pytest
def _wire(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *texts: str
) -> 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: ")

View File

@ -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": (
"<p>Given <code>nums</code>, return <strong>indices</strong> "
"<em>exactly</em> once.</p><ul><li>one</li></ul><br>"
"<div><span>x</span></div><pre>Input: [2,7]\n Output: [0,1]</pre><b>b</b><i>i</i>"
),
"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: <urlopen error offline>"),
(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

View File

@ -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(...)"]

View File

@ -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] <url-or-slug> [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]")

View File

@ -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()

View File

@ -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("<think>x</think>\nHi") == "Hi"
assert local_backend.strip_think("Hi <think>never closed") == "Hi"
assert local_backend.strip_think("reasoning...\n</think>\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("<think>plan</think>Answer")
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)

View File

@ -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

View File

@ -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")

View File

@ -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 &amp; 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] <cards.json | -> (- = 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")

View File

@ -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": "<command-message>leetcode</command-message>\n"
"<command-name>/leetcode</command-name>"
},
}
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"