diff --git a/.claude/skills/campaign-issues/SKILL.md b/.claude/skills/campaign-issues/SKILL.md new file mode 100644 index 0000000..3ce928b --- /dev/null +++ b/.claude/skills/campaign-issues/SKILL.md @@ -0,0 +1,158 @@ +--- +name: campaign-issues +description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog. +--- + +# Campaign issues + +The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a +set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly +(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS, +digest, charts, and Project mirror pick it up automatically. Get the labels/body shape +right; everything downstream is reconciliation. + +All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`. + +## Problem issue + +One issue per LeetCode problem, attached as a sub-issue of its topic. + +- **Title:** `LC · · <Difficulty> · <set>` + - `<Difficulty>` ∈ `Easy | Medium | Hard` (LeetCode's own rating — do not invent). + - `<set>` ∈ `core | optional | deferred` (see set semantics below). + - Separators are middle dots `·` (U+00B7), single spaces around each. +- **Labels** (four, all required): + - `problem` + - `diff:easy` | `diff:medium` | `diff:hard` — matches `<Difficulty>`. + - `set:core` | `set:optional` | `set:deferred` — matches `<set>`. + - `phase:N` — the **topic's** phase (same phase label the topic issue carries). +- **Milestone:** + - `core` / `optional` → the topic's phase milestone. + - `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8). + - Pass the milestone **title**, not its number, to `--milestone`. +- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number): + + ``` + https://leetcode.com/problems/<slug>/ + + - **Topic:** #<topic-issue> + - **Set:** <core|optional|deferred> + - **Language:** Python + + ### Close-out (paste before closing this issue) + - Time: O(?) + - Space: O(?) + - Edge case traced: + - First attempt passed: yes / no + - Miss note: + ``` + +Create it: + +```sh +gh issue create --repo prdlk/leetcode \ + --title "LC 42 · Trapping Rain Water · Hard · core" \ + --label problem --label set:core --label diff:hard --label phase:1 \ + --milestone "Phase I — Linear Structures" \ + --body "https://leetcode.com/problems/trapping-rain-water/ + +- **Topic:** #9 +- **Set:** core +- **Language:** Python + +### Close-out (paste before closing this issue) +- Time: O(?) +- Space: O(?) +- Edge case traced: +- First attempt passed: yes / no +- Miss note:" +``` + +Then **attach it as a sub-issue of the topic** (this is a separate API call — creating +the issue does not link it). The sub-issues API wants the child's internal **id**, not +its issue number, and the field is integer-typed, so use `-F` (not `-f`): + +```sh +child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id') +gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id +# verify +gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"' +``` + +## Topic issue + +One issue per curriculum topic; owns the problem sub-issues. + +- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`). +- **Labels:** `topic`, `phase:N`. +- **Milestone:** that phase's milestone (title, not number). +- **Body:** + + ``` + **Scheduled:** <Weekday Mon DD, YYYY> + **Phase:** <Roman> — <Phase name> (Week N) + **Builds on:** Topic <n> + + ### Trigger + TODO + + ### Invariant + TODO + + ### Trap + TODO + + ### Close-out ritual + Before every submit, out loud: + 1. State time and space complexity. + 2. Trace one edge case. + + ### Day rules + - 90-minute coding cap. Stop at the cap, even mid-problem. + - Core problems first. Optional only if the core is done. + - Finished early? Do this topic's optional set. Do not start tomorrow. + ``` + +`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json` +(day → topic issue) — not guessed. + +## Reference tables + +Set semantics (from the label descriptions): + +| set | meaning | milestone | +|---|---|---| +| `core` | must solve on the topic day | topic's phase milestone | +| `optional` | solve only if the clock allows | topic's phase milestone | +| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` | + +Milestones (`--milestone` takes the **title**): + +| # | title | +|---|---| +| 1 | Phase I — Linear Structures | +| 2 | Phase II — Nodal & Grid | +| 3 | Phase III — Hierarchical | +| 4 | Phase IV — Relational | +| 5 | Phase V — Decision Space | +| 6 | Review — Transfer & Mocks | + +Find a topic issue and confirm its phase before creating problems under it: + +```sh +gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \ + --json number,title,labels,milestone +``` + +## Gotchas + +- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API + rejects it with `is not of type integer`. +- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque + integer), never its display `#number`. +- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise). +- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is + reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what + close problem issues (via `close-solved.yml`), which then roll up to close topics. +- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A + Hard can be `core` if we deliberately schedule it (vs the usual `deferred`). diff --git a/.claude/skills/project-fields/SKILL.md b/.claude/skills/project-fields/SKILL.md new file mode 100644 index 0000000..f3f7c70 --- /dev/null +++ b/.claude/skills/project-fields/SKILL.md @@ -0,0 +1,108 @@ +--- +name: project-fields +description: Reconcile the "Interview Prep" GitHub Project (user project #2) fields — Set and Difficulty projected from issue labels — and understand which fields the Worker mirrors vs which live only in D1. Use when asked to sync/fix/backfill Project fields, when Project columns look empty or stale, or when adding a field to the project. +--- + +# Interview Prep Project fields + +The user-level Project **"Interview Prep"** (`prdlk` project #2, id `PVT_kwHOADoNjs4BhV1a`) +is a *board over* the issue catalog, never a source of truth. Every field is a projection +of something owned elsewhere. + +## Field ownership — the rule that matters + +| Field | Owner | Written by | +|---|---|---| +| `Set` (Core/Optional/Deferred) | issue label `set:*` | `sync-project-fields`, last step of `close-solved.yml` | +| `Difficulty` (Easy/Medium/Hard) | issue label `diff:*` | `sync-project-fields`, last step of `close-solved.yml` | +| `Target Date` | D1 `next_review` | Worker mirror, per log (`apps/api/src/mirror.ts`) | +| `Status`, `Labels`, `Milestone`, `Parent issue`, … | GitHub built-ins | GitHub | + +**SRS stage and first-attempt result are deliberately NOT Project fields.** They live only +in D1; the charts, digest and `/api/stats` read them from there. Duplicating them into +Project single-selects bought nothing but drift and two extra writes per log. Do not re-add +them to the project, and do not re-add `setStage`/`setFirstAttempt` to `mirror.ts`. + +## Reconciling Set / Difficulty + +`apps/cli/sync-project-fields.ts` recomputes every desired value from the issue labels and +writes only what differs — same reconcile-don't-react shape as `close-solved`. + +**In CI it runs itself.** It is the final step of `.github/workflows/close-solved.yml`, so +any push under `work/` reconciles the board along with the issues and D1. The step is +`continue-on-error: true` and skips cleanly when `PROJECT_PAT` is unset — D1 and the issues +are the truth, so a Project failure must never fail a run that already closed issues and +logged the solve. It honours the workflow's `dry_run` input via `DRY_RUN=true`. + +Run it by hand when issues changed without a `work/` push (e.g. you just filed a problem +issue, or relabelled one): + +```sh +# needs a token with the `project` scope — neither `gh auth token` nor GITHUB_TOKEN has it. +export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-) + +bun run sync-project-fields -- --dry-run # or DRY_RUN=1|true; prints the table, writes nothing +bun run sync-project-fields # apply +bun run sync-project-fields # re-run: must report 0 writes +``` + +Invariants the script holds — preserve them in any edit: + +- **Only `problem`-labelled items are touched.** Topic, cadence and review issues have no + set/difficulty; they are skipped, never written and never cleared. +- **Idempotent.** A second run reports `0 field value(s)`. That re-run *is* the verification. +- **Labels are truth.** Never "fix" a field by hand in the UI — fix the label, then reconcile. + A hand-edited field is silently reverted on the next run. +- **Fields and options resolve by NAME**, so re-creating a field in the UI doesn't strand a + dead id. A missing field or option is a hard error, not a silent skip. +- **A problem missing a `set:`/`diff:` label** warns and is left untouched (it means the + issue is mislabelled — fix it with the `campaign-issues` skill). + +## Auth gotcha + +Project v2 needs the `project` scope. The local `gh` login has only +`gist, read:org, repo, workflow`, so **any** `gh project`/GraphQL project call fails with +the default token. Use the Worker's `GH_PAT` from `apps/api/.dev.vars` via `GH_TOKEN` +(never echo it). Symptom of the wrong token: `Resource not accessible by personal access +token` or an empty `projectsV2.nodes`. + +## Inspecting the project + +```sh +export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-) + +# fields + single-select options +gh api graphql -f owner=prdlk -f title="Interview Prep" -f query=' +query($owner: String!, $title: String!) { + user(login: $owner) { projectsV2(first: 10, query: $title) { nodes { id title number + items(first: 1) { totalCount } + fields(first: 30) { nodes { + ... on ProjectV2FieldCommon { id name dataType } + ... on ProjectV2SingleSelectField { options { id name } } } } } } } +}' --jq '.data.user.projectsV2.nodes[] | select(.title=="Interview Prep") + | .fields.nodes[] | "\(.name)\t\(.dataType)\t\((.options // []) | map(.name) | join(","))"' +``` + +Writes use `updateProjectV2ItemFieldValue` with +`value: { singleSelectOptionId: … }` (dates use `{ date: "YYYY-MM-DD" }`); clearing uses +`clearProjectV2ItemFieldValue`. An item id is *not* an issue number — resolve it from the +project's `items` connection (or the issue's `projectItems`), as `mirror.ts` does. + +## Adding a new projected field + +1. Create the field + options in the Project UI. +2. Add it to the `FIELDS` table in `apps/cli/sync-project-fields.ts` — one entry mapping + label → option name. Nothing else needs to change; resolution, diffing, dry-run and + reporting are generic over that table. +3. Dry-run, apply, re-run to prove idempotency. + +If the new field's truth lives in **D1** rather than a label, it belongs in the Worker +mirror instead (`apps/api/src/mirror.ts`) — but read the ownership rule above first and +default to *not* mirroring. + +## Known trap + +`resolve()` in `mirror.ts` throws if a field it asks for is absent, *before* caching +`info` — so one missing field breaks **every** mirrored write, including `Target Date`, +and only as a `console.warn`. If Target Date silently stops updating, check that every +field `resolve()` looks up still exists in the project.