From e06df74caf7a36d4ff828f5e9bcb2ac8982d2818 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 23 Oct 2026 19:23:39 +0200 Subject: [PATCH] Coach tab: no sessions yet is a known state with how to start The exporter used to publish coach: null before the first session, so the tab printed "unknown" everywhere with no way forward. Now a missing or empty sessions dir exports empty problems/re-tests, every cluster at zero with its problems (slug, number, title, URL) and next, and a how_to block (commands, the 4-step method, first command). null stays for a real failure. how_to lives once in leetcode_coach/how_to.py; DOCS-how-to.md quotes it and a test fails on drift. The app's Coach tab shows a How to start card (monospace command box with copy), one card per cluster with tappable problems, re-tests with their command, and "unknown" + why only for a null section. Also fixes the done/solved key mismatch (the app read a key no exporter wrote). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi --- leetcode_coach/leetcode_coach/DOCS-how-to.md | 20 +++++ leetcode_coach/leetcode_coach/SKILL.md | 2 + leetcode_coach/leetcode_coach/cluster_cli.py | 5 +- leetcode_coach/leetcode_coach/clusters.json | 48 ++++++------ leetcode_coach/leetcode_coach/how_to.py | 78 +++++++++++++++++++ .../leetcode_coach/tests/test_how_to.py | 64 +++++++++++++++ 6 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 leetcode_coach/leetcode_coach/DOCS-how-to.md create mode 100644 leetcode_coach/leetcode_coach/how_to.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_how_to.py diff --git a/leetcode_coach/leetcode_coach/DOCS-how-to.md b/leetcode_coach/leetcode_coach/DOCS-how-to.md new file mode 100644 index 0000000..ba14d08 --- /dev/null +++ b/leetcode_coach/leetcode_coach/DOCS-how-to.md @@ -0,0 +1,20 @@ +# The commands and the method, as the status app's Coach tab shows them + +Quoted verbatim from `how_to.py`; `tests/test_how_to.py` fails if they differ. +SKILL.md section 3 is the full method; this is its one-line-per-step form. + +Commands: + +- `leetcode --cluster ` - Serve the next problem of a concept cluster (a due re-test comes first). +- `leetcode ` - Coach one problem, from its LeetCode URL or slug. +- `leetcode due` - List the cold re-tests that are due. +- `leetcode report` - One row per session: requirements before code, hint rung, reveal. + +The command skips problems LeetCode already shows as Accepted, so it may serve a later one than listed here. + +The method: + +1. **Constraint** - Turn the hard constraint into a rule for the design. +2. **Requirements** - List what the operation must do, as properties, never by name. +3. **Toolbox** - Search the toolbox for an operation with those properties. +4. **Post-mortem** - Say which hint gave which requirement, then make the cards. diff --git a/leetcode_coach/leetcode_coach/SKILL.md b/leetcode_coach/leetcode_coach/SKILL.md index 49bf0be..0763248 100644 --- a/leetcode_coach/leetcode_coach/SKILL.md +++ b/leetcode_coach/leetcode_coach/SKILL.md @@ -244,3 +244,5 @@ The right client is `leetcode ` (print mode: the Stop hook can still withhold a reply; the TUI has already streamed it). `leetcode --local` runs offline on Ollama; everything above `## Model and client` is its system prompt. Clients, gates and the session log: `DOCS-clients.md` here. + +The commands and the method as the status app shows them: `DOCS-how-to.md`. diff --git a/leetcode_coach/leetcode_coach/cluster_cli.py b/leetcode_coach/leetcode_coach/cluster_cli.py index 667e6a9..05aad6c 100644 --- a/leetcode_coach/leetcode_coach/cluster_cli.py +++ b/leetcode_coach/leetcode_coach/cluster_cli.py @@ -27,7 +27,7 @@ import logging import os import sys -from leetcode_coach import progress +from leetcode_coach import how_to, progress from leetcode_guard._live_solved import check_solved from leetcode_guard._settings import build_client @@ -66,9 +66,10 @@ def _due() -> int: print("no re-tests due") return 0 for row in rows: + command = how_to.retest_command(row["slug"]) print( f"{row['due']} {row['slug']} re-test {row['stage']}/{STAGES} " - f"({row['cluster'] or 'no cluster'}) -> leetcode --retest {row['slug']}" + f"({row['cluster'] or 'no cluster'}) -> {command}" ) return 0 diff --git a/leetcode_coach/leetcode_coach/clusters.json b/leetcode_coach/leetcode_coach/clusters.json index 1d3dc45..bd0263b 100644 --- a/leetcode_coach/leetcode_coach/clusters.json +++ b/leetcode_coach/leetcode_coach/clusters.json @@ -1,40 +1,40 @@ { "clusters": { "bit-cancellation": [ - {"id": 268, "slug": "missing-number"}, - {"id": 260, "slug": "single-number-iii"}, - {"id": 169, "slug": "majority-element"}, - {"id": 137, "slug": "single-number-ii"} + {"id": 268, "slug": "missing-number", "title": "Missing Number"}, + {"id": 260, "slug": "single-number-iii", "title": "Single Number III"}, + {"id": 169, "slug": "majority-element", "title": "Majority Element"}, + {"id": 137, "slug": "single-number-ii", "title": "Single Number II"} ], "two-pointers": [ - {"id": 167, "slug": "two-sum-ii-input-array-is-sorted"}, - {"id": 15, "slug": "3sum"}, - {"id": 11, "slug": "container-with-most-water"}, - {"id": 42, "slug": "trapping-rain-water"} + {"id": 167, "slug": "two-sum-ii-input-array-is-sorted", "title": "Two Sum II - Input Array Is Sorted"}, + {"id": 15, "slug": "3sum", "title": "3Sum"}, + {"id": 11, "slug": "container-with-most-water", "title": "Container With Most Water"}, + {"id": 42, "slug": "trapping-rain-water", "title": "Trapping Rain Water"} ], "sliding-window": [ - {"id": 3, "slug": "longest-substring-without-repeating-characters"}, - {"id": 424, "slug": "longest-repeating-character-replacement"}, - {"id": 567, "slug": "permutation-in-string"}, - {"id": 76, "slug": "minimum-window-substring"} + {"id": 3, "slug": "longest-substring-without-repeating-characters", "title": "Longest Substring Without Repeating Characters"}, + {"id": 424, "slug": "longest-repeating-character-replacement", "title": "Longest Repeating Character Replacement"}, + {"id": 567, "slug": "permutation-in-string", "title": "Permutation in String"}, + {"id": 76, "slug": "minimum-window-substring", "title": "Minimum Window Substring"} ], "prefix-sum-hash": [ - {"id": 560, "slug": "subarray-sum-equals-k"}, - {"id": 523, "slug": "continuous-subarray-sum"}, - {"id": 974, "slug": "subarray-sums-divisible-by-k"}, - {"id": 525, "slug": "contiguous-array"} + {"id": 560, "slug": "subarray-sum-equals-k", "title": "Subarray Sum Equals K"}, + {"id": 523, "slug": "continuous-subarray-sum", "title": "Continuous Subarray Sum"}, + {"id": 974, "slug": "subarray-sums-divisible-by-k", "title": "Subarray Sums Divisible by K"}, + {"id": 525, "slug": "contiguous-array", "title": "Contiguous Array"} ], "monotonic-stack": [ - {"id": 739, "slug": "daily-temperatures"}, - {"id": 496, "slug": "next-greater-element-i"}, - {"id": 503, "slug": "next-greater-element-ii"}, - {"id": 84, "slug": "largest-rectangle-in-histogram"} + {"id": 739, "slug": "daily-temperatures", "title": "Daily Temperatures"}, + {"id": 496, "slug": "next-greater-element-i", "title": "Next Greater Element I"}, + {"id": 503, "slug": "next-greater-element-ii", "title": "Next Greater Element II"}, + {"id": 84, "slug": "largest-rectangle-in-histogram", "title": "Largest Rectangle in Histogram"} ], "intervals": [ - {"id": 56, "slug": "merge-intervals"}, - {"id": 57, "slug": "insert-interval"}, - {"id": 435, "slug": "non-overlapping-intervals"}, - {"id": 452, "slug": "minimum-number-of-arrows-to-burst-balloons"} + {"id": 56, "slug": "merge-intervals", "title": "Merge Intervals"}, + {"id": 57, "slug": "insert-interval", "title": "Insert Interval"}, + {"id": 435, "slug": "non-overlapping-intervals", "title": "Non-overlapping Intervals"}, + {"id": 452, "slug": "minimum-number-of-arrows-to-burst-balloons", "title": "Minimum Number of Arrows to Burst Balloons"} ] } } diff --git a/leetcode_coach/leetcode_coach/how_to.py b/leetcode_coach/leetcode_coach/how_to.py new file mode 100644 index 0000000..f7f42ba --- /dev/null +++ b/leetcode_coach/leetcode_coach/how_to.py @@ -0,0 +1,78 @@ +"""The commands and the method, written once. + +The status app's Coach tab shows these next to an empty session list, and +``DOCS-how-to.md`` (which SKILL.md points at) quotes the same lines verbatim; +a test fails when the two differ, which is the point of having one module. Nothing +here names a toolbox operation: the Stop hook bounces that before kuhy's +first code, and the same words on a screen would teach skipping the search. + +Stdlib only, like ``progress``: the status snapshot imports this every +minute on the system python. +""" + +from __future__ import annotations + +from typing import Any, NamedTuple + + +class Command(NamedTuple): + """One thing kuhy can type, and what it does.""" + + command: str + meaning: str + + +class Step(NamedTuple): + """One step of the method.""" + + name: str + text: str + + +COMMANDS = ( + Command( + "leetcode --cluster ", + "Serve the next problem of a concept cluster (a due re-test comes first).", + ), + Command("leetcode ", "Coach one problem, from its LeetCode URL or slug."), + Command("leetcode due", "List the cold re-tests that are due."), + Command( + "leetcode report", + "One row per session: requirements before code, hint rung, reveal.", + ), +) +STEPS = ( + Step("Constraint", "Turn the hard constraint into a rule for the design."), + Step( + "Requirements", + "List what the operation must do, as properties, never by name.", + ), + Step("Toolbox", "Search the toolbox for an operation with those properties."), + Step("Post-mortem", "Say which hint gave which requirement, then make the cards."), +) +NEXT_NOTE = ( + "The command skips problems LeetCode already shows as Accepted, " + "so it may serve a later one than listed here." +) + + +def cluster_command(name: str) -> str: + """The command that starts cluster ``name``.""" + return f"leetcode --cluster {name}" + + +def retest_command(slug: str) -> str: + """The command that starts the cold re-test of ``slug``.""" + return f"leetcode --retest {slug}" + + +def block(first_cluster: str | None) -> dict[str, Any]: + """The snapshot's ``coach.how_to``; ``first_cluster`` names the first command.""" + return { + "commands": [c._asdict() for c in COMMANDS], + "steps": [s._asdict() for s in STEPS], + "next_note": NEXT_NOTE, + "first_command": ( + None if first_cluster is None else cluster_command(first_cluster) + ), + } diff --git a/leetcode_coach/leetcode_coach/tests/test_how_to.py b/leetcode_coach/leetcode_coach/tests/test_how_to.py new file mode 100644 index 0000000..7d29cef --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_how_to.py @@ -0,0 +1,64 @@ +"""`how_to` is the one source of the commands and method the status app shows. + +SKILL.md quotes it, the Coach tab renders it, and these tests are what stop +the two copies from drifting apart (the reason it is one module). +""" + +from __future__ import annotations + +from pathlib import Path +import re + +from leetcode_coach import cluster_cli, how_to, report + +DOC = Path(__file__).parents[1] / "DOCS-how-to.md" +SKILL = Path(__file__).parents[1] / "SKILL.md" + + +def test_block_is_commands_steps_note_and_the_first_command() -> None: + block = how_to.block("two-pointers") + + assert block["first_command"] == "leetcode --cluster two-pointers" + assert len(block["commands"]) == 4 + assert len(block["steps"]) == 4 + assert block["next_note"] == how_to.NEXT_NOTE + assert all(c["command"] and c["meaning"] for c in block["commands"]) + assert all(s["name"] and s["text"] for s in block["steps"]) + + +def test_no_first_cluster_means_no_first_command() -> None: + assert how_to.block(None)["first_command"] is None + + +def test_command_templates() -> None: + assert how_to.cluster_command("intervals") == "leetcode --cluster intervals" + assert how_to.retest_command("3sum") == "leetcode --retest 3sum" + + +def test_every_listed_command_is_one_the_launcher_dispatches() -> None: + known = {*cluster_cli.COMMANDS, report.COMMAND} + for entry in how_to.COMMANDS: + word = entry.command.split()[1] + assert word in known or word == "", entry.command + + +def test_the_doc_quotes_every_command_and_step_verbatim() -> None: + skill = DOC.read_text(encoding="utf-8") + + for entry in how_to.COMMANDS: + assert f"`{entry.command}` - {entry.meaning}" in skill + for number, step in enumerate(how_to.STEPS, start=1): + assert f"{number}. **{step.name}** - {step.text}" in skill + assert how_to.NEXT_NOTE in skill + + +def test_skill_md_points_at_the_doc() -> None: + assert "DOCS-how-to.md" in SKILL.read_text(encoding="utf-8") + + +def test_the_method_never_names_a_toolbox_operation() -> None: + # The Stop hook bounces a reply that names one before kuhy's first code; + # the same words on the Coach tab would teach kuhy to skip the search. + text = " ".join(s.text.casefold() for s in how_to.STEPS) + + assert re.search(r"\b(xor|hash map|sort|stack|two pointers)\b", text) is None