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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 19:23:39 +02:00
parent 2d2d8a41f8
commit e06df74caf
6 changed files with 191 additions and 26 deletions

View File

@ -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 <name>` - Serve the next problem of a concept cluster (a due re-test comes first).
- `leetcode <url>` - 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.

View File

@ -244,3 +244,5 @@ The right client is `leetcode <url>` (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`.

View File

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

View File

@ -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"}
]
}
}

View File

@ -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 <name>",
"Serve the next problem of a concept cluster (a due re-test comes first).",
),
Command("leetcode <url>", "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)
),
}

View File

@ -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 == "<url>", 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