diff --git a/.github/srs/badge.json b/.github/srs/badge.json
new file mode 100644
index 0000000..9248027
--- /dev/null
+++ b/.github/srs/badge.json
@@ -0,0 +1,6 @@
+{
+ "schemaVersion": 1,
+ "label": "gate pass rate",
+ "message": "no gates yet",
+ "color": "lightgrey"
+}
diff --git a/.github/srs/schedule.json b/.github/srs/schedule.json
new file mode 100644
index 0000000..49fcd85
--- /dev/null
+++ b/.github/srs/schedule.json
@@ -0,0 +1,26 @@
+{
+ "2026-08-17": 3,
+ "2026-08-18": 4,
+ "2026-08-24": 5,
+ "2026-08-25": 6,
+ "2026-08-26": 7,
+ "2026-08-27": 8,
+ "2026-08-28": 9,
+ "2026-08-31": 10,
+ "2026-09-01": 11,
+ "2026-09-02": 12,
+ "2026-09-03": 13,
+ "2026-09-04": 14,
+ "2026-09-07": 15,
+ "2026-09-08": 16,
+ "2026-09-09": 17,
+ "2026-09-10": 18,
+ "2026-09-11": 19,
+ "2026-09-14": 20,
+ "2026-09-15": 21,
+ "2026-09-16": 22,
+ "2026-09-17": 23,
+ "2026-09-18": 24,
+ "2026-09-21": 25,
+ "2026-09-22": 26
+}
diff --git a/.github/srs/srs.json b/.github/srs/srs.json
new file mode 100644
index 0000000..26e79d7
--- /dev/null
+++ b/.github/srs/srs.json
@@ -0,0 +1,548 @@
+{
+ "problems": {
+ "1": {
+ "issue": 29,
+ "topic": 3,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "4": {
+ "issue": 66,
+ "topic": 7,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-29",
+ "defer_until": "2026-09-29",
+ "history": []
+ },
+ "11": {
+ "issue": 44,
+ "topic": 5,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "15": {
+ "issue": 43,
+ "topic": 5,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "33": {
+ "issue": 60,
+ "topic": 7,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "42": {
+ "issue": 49,
+ "topic": 5,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-28",
+ "defer_until": "2026-09-28",
+ "history": []
+ },
+ "49": {
+ "issue": 30,
+ "topic": 3,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "52": {
+ "issue": 156,
+ "topic": 22,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-03",
+ "defer_until": "2026-10-03",
+ "history": []
+ },
+ "72": {
+ "issue": 171,
+ "topic": 24,
+ "difficulty": "medium",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-03",
+ "defer_until": "2026-10-03",
+ "history": []
+ },
+ "76": {
+ "issue": 57,
+ "topic": 6,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-28",
+ "defer_until": "2026-09-28",
+ "history": []
+ },
+ "84": {
+ "issue": 76,
+ "topic": 9,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-30",
+ "defer_until": "2026-09-30",
+ "history": []
+ },
+ "97": {
+ "issue": 172,
+ "topic": 24,
+ "difficulty": "medium",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-04",
+ "defer_until": "2026-10-04",
+ "history": []
+ },
+ "121": {
+ "issue": 50,
+ "topic": 6,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "124": {
+ "issue": 105,
+ "topic": 13,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-30",
+ "defer_until": "2026-09-30",
+ "history": []
+ },
+ "125": {
+ "issue": 41,
+ "topic": 5,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "167": {
+ "issue": 42,
+ "topic": 5,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "212": {
+ "issue": 129,
+ "topic": 17,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-02",
+ "defer_until": "2026-10-02",
+ "history": []
+ },
+ "217": {
+ "issue": 27,
+ "topic": 3,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "221": {
+ "issue": 173,
+ "topic": 24,
+ "difficulty": "medium",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-04",
+ "defer_until": "2026-10-04",
+ "history": []
+ },
+ "224": {
+ "issue": 71,
+ "topic": 8,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-09-29",
+ "defer_until": "2026-09-29",
+ "history": []
+ },
+ "238": {
+ "issue": 37,
+ "topic": 4,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "242": {
+ "issue": 28,
+ "topic": 3,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "269": {
+ "issue": 145,
+ "topic": 20,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-02",
+ "defer_until": "2026-10-02",
+ "history": []
+ },
+ "295": {
+ "issue": 125,
+ "topic": 16,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-01",
+ "defer_until": "2026-10-01",
+ "history": []
+ },
+ "303": {
+ "issue": 36,
+ "topic": 4,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "347": {
+ "issue": 31,
+ "topic": 3,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "383": {
+ "issue": 32,
+ "topic": 3,
+ "difficulty": "easy",
+ "set": "optional",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "392": {
+ "issue": 45,
+ "topic": 5,
+ "difficulty": "easy",
+ "set": "optional",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "502": {
+ "issue": 126,
+ "topic": 16,
+ "difficulty": "hard",
+ "set": "deferred",
+ "stage": "new",
+ "next_review": "2026-10-01",
+ "defer_until": "2026-10-01",
+ "history": []
+ },
+ "560": {
+ "issue": 38,
+ "topic": 4,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "704": {
+ "issue": 58,
+ "topic": 7,
+ "difficulty": "easy",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "875": {
+ "issue": 59,
+ "topic": 7,
+ "difficulty": "medium",
+ "set": "core",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ },
+ "977": {
+ "issue": 46,
+ "topic": 5,
+ "difficulty": "easy",
+ "set": "optional",
+ "solved_on": "2026-08-24",
+ "stage": "+2",
+ "next_review": "2026-08-26",
+ "history": [
+ {
+ "date": "2026-08-24",
+ "kind": "first",
+ "result": "pass"
+ }
+ ]
+ }
+ },
+ "topics": {
+ "3": {
+ "misses": 0,
+ "boost": false
+ },
+ "4": {
+ "misses": 0,
+ "boost": false
+ },
+ "5": {
+ "misses": 0,
+ "boost": false
+ },
+ "6": {
+ "misses": 0,
+ "boost": false
+ },
+ "7": {
+ "misses": 0,
+ "boost": false
+ },
+ "8": {
+ "misses": 0,
+ "boost": false
+ },
+ "9": {
+ "misses": 0,
+ "boost": false
+ },
+ "10": {
+ "misses": 0,
+ "boost": false
+ },
+ "11": {
+ "misses": 0,
+ "boost": false
+ },
+ "12": {
+ "misses": 0,
+ "boost": false
+ },
+ "13": {
+ "misses": 0,
+ "boost": false
+ },
+ "14": {
+ "misses": 0,
+ "boost": false
+ },
+ "15": {
+ "misses": 0,
+ "boost": false
+ },
+ "16": {
+ "misses": 0,
+ "boost": false
+ },
+ "17": {
+ "misses": 0,
+ "boost": false
+ },
+ "18": {
+ "misses": 0,
+ "boost": false
+ },
+ "19": {
+ "misses": 0,
+ "boost": false
+ },
+ "20": {
+ "misses": 0,
+ "boost": false
+ },
+ "21": {
+ "misses": 0,
+ "boost": false
+ },
+ "22": {
+ "misses": 0,
+ "boost": false
+ },
+ "23": {
+ "misses": 0,
+ "boost": false
+ },
+ "24": {
+ "misses": 0,
+ "boost": false
+ },
+ "25": {
+ "misses": 0,
+ "boost": false
+ },
+ "26": {
+ "misses": 0,
+ "boost": false
+ }
+ },
+ "drill_pool_used": [],
+ "gates": []
+}
diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml
new file mode 100644
index 0000000..d821751
--- /dev/null
+++ b/.github/workflows/gate.yml
@@ -0,0 +1,67 @@
+name: Gate
+
+on:
+ schedule:
+ - cron: "0 12 * * 6" # Saturday 8 AM ET β create this week's gate
+ workflow_dispatch:
+ issues:
+ types: [closed] # grade a gate when it closes
+
+permissions:
+ contents: write
+ issues: write
+
+# Shared with srs-scheduler / srs-logger / srs-setup: never race on srs.json.
+concurrency:
+ group: srs-state
+ cancel-in-progress: false
+
+jobs:
+ create:
+ # Trigger A: the only issue-creating workflow in the SRS. The script
+ # no-ops when this week's gate already exists.
+ if: github.event_name != 'issues'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - run: bun scripts/srs-gate.ts create
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Commit state
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add .github/srs
+ git diff --cached --quiet && { echo "no state change"; exit 0; }
+ git commit -m "srs: create weekly gate [skip ci]"
+ for i in 1 2 3; do git push && exit 0; git pull --rebase; done
+ git push
+
+ close:
+ # Trigger B: grade, log, boost, badge.
+ if: >-
+ github.event_name == 'issues' &&
+ contains(github.event.issue.labels.*.name, 'gate')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - run: bun scripts/srs-gate.ts close
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Commit state
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add .github/srs docs/gate-log.md
+ git diff --cached --quiet && { echo "no state change"; exit 0; }
+ git commit -m "srs: grade gate #${{ github.event.issue.number }} [skip ci]"
+ for i in 1 2 3; do git push && exit 0; git pull --rebase; done
+ git push
diff --git a/.github/workflows/srs-logger.yml b/.github/workflows/srs-logger.yml
new file mode 100644
index 0000000..7f19dcc
--- /dev/null
+++ b/.github/workflows/srs-logger.yml
@@ -0,0 +1,43 @@
+name: SRS Logger
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ contents: write
+ issues: write
+
+# Shared with srs-scheduler / gate / srs-setup: never race on srs.json.
+concurrency:
+ group: srs-state
+ cancel-in-progress: false
+
+jobs:
+ log:
+ # Owner-only, and only on the daily brief or a gate issue. Put every
+ # /done line in ONE comment β queued duplicate runs may be superseded.
+ if: >-
+ github.event.comment.user.login == github.repository_owner &&
+ (github.event.issue.title == 'π Today' ||
+ contains(github.event.issue.labels.*.name, 'gate'))
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - run: bun scripts/srs-logger.ts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
+
+ - name: Commit state
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add .github/srs
+ git diff --cached --quiet && { echo "no state change"; exit 0; }
+ git commit -m "srs: log results from #${{ github.event.issue.number }} [skip ci]"
+ for i in 1 2 3; do git push && exit 0; git pull --rebase; done
+ git push
diff --git a/.github/workflows/srs-scheduler.yml b/.github/workflows/srs-scheduler.yml
new file mode 100644
index 0000000..a6b9c2e
--- /dev/null
+++ b/.github/workflows/srs-scheduler.yml
@@ -0,0 +1,30 @@
+name: SRS Scheduler
+
+on:
+ schedule:
+ - cron: "0 10 * * *" # 6 AM ET β before the day starts
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+
+# Shared with srs-logger / gate / srs-setup: never race on srs.json.
+concurrency:
+ group: srs-state
+ cancel-in-progress: false
+
+jobs:
+ brief:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ # Stateless: reads srs.json + schedule.json, rewrites the pinned
+ # π Today issue, mirrors Target Date. Commits nothing.
+ - run: bun scripts/srs-scheduler.ts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
diff --git a/.github/workflows/srs-setup.yml b/.github/workflows/srs-setup.yml
new file mode 100644
index 0000000..bc97f97
--- /dev/null
+++ b/.github/workflows/srs-setup.yml
@@ -0,0 +1,29 @@
+name: SRS Setup
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: read
+
+# Shared with the other SRS workflows: never race on srs.json.
+concurrency:
+ group: srs-state
+ cancel-in-progress: false
+
+jobs:
+ setup:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ # Verifies PROJECT_PAT against the user project, creates the SRS
+ # single-select fields when missing, and re-mirrors every laddered
+ # problem's Target Date / SRS Stage / First Attempt. Idempotent.
+ - run: bun scripts/srs-setup.ts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
diff --git a/README.md b/README.md
index a120638..99b3bd5 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
+
diff --git a/docs/gate-log.md b/docs/gate-log.md
new file mode 100644
index 0000000..c484e51
--- /dev/null
+++ b/docs/gate-log.md
@@ -0,0 +1,7 @@
+# Gate log
+
+Weekly topic-blind gates. Target: 70% first-attempt pass rate. Rows appended
+automatically by `gate.yml` when a gate issue closes.
+
+| Week | Date | Problems | First-attempt rate | Result |
+| --- | --- | --- | --- | --- |
diff --git a/scripts/srs-gate.ts b/scripts/srs-gate.ts
new file mode 100755
index 0000000..5d6817d
--- /dev/null
+++ b/scripts/srs-gate.ts
@@ -0,0 +1,261 @@
+#!/usr/bin/env bun
+/**
+ * Weekly gate: create Saturday's topic-blind quiz, and grade it on close.
+ *
+ * bun scripts/srs-gate.ts create # Trigger A (cron Sat / dispatch)
+ * bun scripts/srs-gate.ts close --issue N # Trigger B (issues: closed)
+ *
+ * create β samples ONE unsolved `set:optional` problem per topic scheduled
+ * THIS week (daily drills only ever draw from earlier weeks, so every gate
+ * problem is a first encounter). Seeded by ISO week number: re-runs pick the
+ * same problems, and an existing `Gate β Week N` issue makes the run a no-op.
+ * Exhausted optional pools fall back to an unsolved core problem, and the
+ * body says so. Boost flags + miss counters from LAST week's gate are cleared
+ * here β the boosted topics had their remedial week.
+ *
+ * close β reads this gate's `/done` results (recorded by srs-logger) from
+ * srs.json, computes the first-attempt pass rate (unlogged problems count as
+ * failures β skipping a gate problem is not a pass), appends docs/gate-log.md,
+ * comments a summary, sets `boost: true` on every topic that failed a gate
+ * problem or accumulated β₯ 2 drill misses, and refreshes the README badge
+ * endpoint at .github/srs/badge.json.
+ */
+import { join } from "node:path";
+import { parseArgs } from "node:util";
+
+import { github } from "./github.ts";
+import {
+ campaignWeek,
+ fetchCatalog,
+ isoWeek,
+ loadSchedule,
+ loadState,
+ rng,
+ sample,
+ saveState,
+ todayET,
+} from "./srs.ts";
+
+const ROOT = join(import.meta.dir, "..");
+const GATE_LOG = process.env.SRS_GATE_LOG ?? join(ROOT, "docs", "gate-log.md");
+const BADGE = process.env.SRS_BADGE ?? join(ROOT, ".github", "srs", "badge.json");
+const PASS_TARGET = 70; // percent, first-attempt
+
+const { positionals, values } = parseArgs({
+ allowPositionals: true,
+ options: { issue: { type: "string" } },
+});
+const mode = positionals[0];
+
+const gh = await github();
+const state = await loadState();
+const today = todayET();
+
+// ββ Trigger A: create this week's gate βββββββββββββββββββββββββββ
+
+async function create(): Promise {
+ const week = campaignWeek(today);
+ const title = `Gate β Week ${week}`;
+
+ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=gate&state=all`)) {
+ const issue = raw as { number: number; title: string };
+ if (issue.title === title) {
+ console.log(`${title} already exists (#${issue.number}); nothing to do`);
+ return;
+ }
+ }
+
+ const schedule = await loadSchedule();
+ const topics = Object.entries(schedule)
+ .filter(([date]) => campaignWeek(date) === week)
+ .sort(([a], [b]) => (a < b ? -1 : 1))
+ .map(([, topic]) => topic);
+ if (topics.length === 0) {
+ console.log(`no topics scheduled in week ${week}; no gate to create`);
+ return;
+ }
+
+ const catalog = await fetchCatalog(gh);
+ const random = rng(`gate-${isoWeek(today)}`);
+ const picks: { lc: number; fallback: boolean }[] = [];
+ for (const topic of topics) {
+ const fresh = (set: string) =>
+ [...catalog.problems.values()]
+ .filter(
+ (p) =>
+ p.topic === topic &&
+ p.set === set &&
+ p.open &&
+ !state.problems[p.lc] &&
+ !state.drill_pool_used.includes(p.lc),
+ )
+ .sort((a, b) => a.lc - b.lc);
+ const optional = fresh("optional");
+ if (optional.length > 0) {
+ picks.push({ lc: sample(optional, 1, random)[0]!.lc, fallback: false });
+ continue;
+ }
+ const core = fresh("core");
+ if (core.length > 0) picks.push({ lc: sample(core, 1, random)[0]!.lc, fallback: true });
+ // Both pools exhausted: the topic is fully solved; nothing to quiz.
+ }
+ if (picks.length === 0) {
+ console.log(`every pool for week ${week} is exhausted; no gate to create`);
+ return;
+ }
+
+ const lines = picks.map(({ lc, fallback }) => {
+ const p = catalog.problems.get(lc)!;
+ return `- [LC ${p.lc}](${p.url}) β ${p.difficulty}${fallback ? " *(core fallback β optional pool exhausted)*" : ""}`;
+ });
+ const body = [
+ `**Target: ${PASS_TARGET}% first-attempt pass rate. Timed. Narrate out loud.**`,
+ "",
+ ...lines,
+ "",
+ "Log with `/done ` comments, then close this issue.",
+ ].join("\n");
+
+ const milestone = catalog.topics.get(topics[0]!)?.milestone;
+ const created = (await gh.api(`/repos/${gh.repo}/issues`, {
+ method: "POST",
+ body: JSON.stringify({ title, body, labels: ["gate"], milestone }),
+ })) as { number: number };
+
+ state.gates.push({
+ week,
+ issue: created.number,
+ date: today,
+ problems: picks.map((p) => p.lc),
+ fallbacks: picks.filter((p) => p.fallback).map((p) => p.lc),
+ results: {},
+ });
+
+ // Last week's boosted topics had their remedial week: clear flags/counters.
+ for (const t of Object.values(state.topics)) {
+ if (t.boost) {
+ t.boost = false;
+ t.misses = 0;
+ }
+ }
+
+ await saveState(state);
+ console.log(`created #${created.number} ${title}: ${picks.map((p) => `LC ${p.lc}`).join(", ")}`);
+}
+
+// ββ Trigger B: grade a closed gate βββββββββββββββββββββββββββββββ
+
+async function close(issueNumber: number): Promise {
+ const gate = state.gates.find((g) => g.issue === issueNumber);
+ if (!gate) {
+ console.log(`issue #${issueNumber} has no gate record in srs.json; ignoring`);
+ return;
+ }
+ if (gate.rate !== undefined) {
+ console.log(`gate #${issueNumber} already graded (${gate.rate}%); nothing to do`);
+ return;
+ }
+
+ const catalog = await fetchCatalog(gh);
+ const passes = gate.problems.filter((lc) => gate.results[lc] === "pass");
+ const unlogged = gate.problems.filter((lc) => gate.results[lc] === undefined);
+ const rate = Math.round((passes.length / gate.problems.length) * 100);
+ gate.rate = rate;
+ const passed = rate >= PASS_TARGET;
+
+ // Boost: explicit gate failures, plus topics with β₯ 2 recognition misses.
+ const boosted: number[] = [];
+ for (const lc of gate.problems) {
+ if (gate.results[lc] === "fail") {
+ const topic = catalog.problems.get(lc)?.topic;
+ if (topic !== undefined) {
+ (state.topics[topic] ??= { misses: 0, boost: false }).boost = true;
+ boosted.push(topic);
+ }
+ }
+ }
+ for (const [topic, t] of Object.entries(state.topics)) {
+ if (t.misses >= 2 && !t.boost) {
+ t.boost = true;
+ boosted.push(Number(topic));
+ }
+ }
+
+ // Gate log row.
+ const log = await Bun.file(GATE_LOG).text();
+ const problems = gate.problems.map((lc) => `LC ${lc}`).join(" Β· ");
+ await Bun.write(
+ GATE_LOG,
+ `${log.trimEnd()}\n| ${gate.week} | ${today} | ${problems} | ${rate}% | ${passed ? "pass" : "fail"} |\n`,
+ );
+
+ // Badge endpoint (shields.io schema).
+ await Bun.write(
+ BADGE,
+ `${JSON.stringify(
+ {
+ schemaVersion: 1,
+ label: "gate pass rate",
+ message: `${rate}%`,
+ color: passed ? "brightgreen" : "red",
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ await saveState(state);
+
+ const summary = [
+ `## Gate β Week ${gate.week}: **${rate}%** first-attempt (target ${PASS_TARGET}%) β ${passed ? "β
pass" : "β fail"}`,
+ "",
+ ...gate.problems.map((lc) => {
+ const r = gate.results[lc];
+ return `- LC ${lc} β ${r === "pass" ? "β
pass" : r === "fail" ? "β fail" : "β¬ not logged (counted as fail)"}`;
+ }),
+ "",
+ unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
+ boosted.length
+ ? `Boosted topics for next week's drills: ${[...new Set(boosted)].map((t) => `#${t}`).join(", ")}.`
+ : "No topics boosted.",
+ "",
+ "Logged to `docs/gate-log.md`.",
+ ]
+ .filter((l) => l !== "")
+ .join("\n");
+ await gh.api(`/repos/${gh.repo}/issues/${issueNumber}/comments`, {
+ method: "POST",
+ body: JSON.stringify({ body: summary }),
+ });
+ console.log(`graded gate #${issueNumber}: ${rate}% (${passed ? "pass" : "fail"})`);
+}
+
+// ββ dispatch βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+async function eventIssueNumber(): Promise {
+ if (values.issue) return Number(values.issue);
+ if (!process.env.GITHUB_EVENT_PATH) return NaN;
+ const event: unknown = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
+ if (
+ event &&
+ typeof event === "object" &&
+ "issue" in event &&
+ event.issue &&
+ typeof event.issue === "object" &&
+ "number" in event.issue &&
+ typeof event.issue.number === "number"
+ ) {
+ return event.issue.number;
+ }
+ return NaN;
+}
+
+if (mode === "create") {
+ await create();
+} else if (mode === "close") {
+ const issueNumber = await eventIssueNumber();
+ if (!Number.isFinite(issueNumber)) throw new Error("close mode needs --issue N or an event");
+ await close(issueNumber);
+} else {
+ throw new Error(`usage: srs-gate.ts create | close --issue N (got "${mode}")`);
+}
diff --git a/scripts/srs-logger.ts b/scripts/srs-logger.ts
new file mode 100755
index 0000000..5428ddb
--- /dev/null
+++ b/scripts/srs-logger.ts
@@ -0,0 +1,226 @@
+#!/usr/bin/env bun
+/**
+ * Process `/done ` comments on the `π Today` brief and on
+ * gate issues, and move problems along the interval ladder.
+ *
+ * Transitions (see srs.ts):
+ * - pass: new β +2 β +5 β +10 β retired; next_review = today + new interval.
+ * - fail: back to +2, due in 2 days.
+ * - First-ever log of a problem enters the ladder at +2 either way; a failed
+ * blind drill additionally increments its topic's recognition-miss counter
+ * (a signal the gate's boost logic reads). Drills join drill_pool_used so
+ * they are never served twice.
+ * - A clean first PASS also closes the problem's sub-issue.
+ *
+ * Feedback is never silent: a clean parse gets a π reaction, anything else
+ * gets a reply naming the exact parse error. Only the repo owner is heard.
+ *
+ * Input: GITHUB_EVENT_PATH (issue_comment payload) in Actions, or
+ * --issue N --author LOGIN --comment-id N --body "..." locally.
+ */
+import { parseArgs } from "node:util";
+
+import { github } from "./github.ts";
+import { projectMirror, reportMirror } from "./srs-project.ts";
+import {
+ type Attempt,
+ addDays,
+ fail,
+ fetchCatalog,
+ loadState,
+ pass,
+ saveState,
+ TODAY_TITLE,
+ todayET,
+} from "./srs.ts";
+
+const DRY = process.env.SRS_DRY === "1";
+
+// ββ event input ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface Input {
+ issue: number;
+ issueTitle: string;
+ issueLabels: string[];
+ author: string;
+ commentId: number;
+ body: string;
+}
+
+async function readInput(): Promise {
+ if (process.env.GITHUB_EVENT_PATH) {
+ const event = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
+ return {
+ issue: event.issue.number,
+ issueTitle: event.issue.title,
+ issueLabels: (event.issue.labels ?? []).map((l: { name: string }) => l.name),
+ author: event.comment.user.login,
+ commentId: event.comment.id,
+ body: event.comment.body ?? "",
+ };
+ }
+ const { values } = parseArgs({
+ options: {
+ issue: { type: "string" },
+ author: { type: "string" },
+ "comment-id": { type: "string" },
+ body: { type: "string" },
+ title: { type: "string" },
+ labels: { type: "string" },
+ },
+ });
+ return {
+ issue: Number(values.issue),
+ issueTitle: values.title ?? TODAY_TITLE,
+ issueLabels: values.labels?.split(",") ?? [],
+ author: values.author ?? "",
+ commentId: Number(values["comment-id"] ?? 0),
+ body: values.body ?? "",
+ };
+}
+
+const gh = await github();
+const input = await readInput();
+const owner = gh.repo.split("/")[0]!;
+
+if (input.author !== owner) {
+ console.log(`ignoring comment by @${input.author} (owner only)`);
+ process.exit(0);
+}
+const isGate = input.issueLabels.includes("gate");
+const isToday = input.issueTitle === TODAY_TITLE;
+if (!isGate && !isToday) {
+ console.log("not the Today brief or a gate issue; ignoring");
+ process.exit(0);
+}
+
+// ββ parse ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface Command {
+ lc: number;
+ result: "pass" | "fail";
+}
+
+const commands: Command[] = [];
+const errors: string[] = [];
+for (const raw of input.body.split("\n")) {
+ const line = raw.trim();
+ if (!line.startsWith("/done")) continue;
+ const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
+ if (m) commands.push({ lc: Number(m[1]), result: m[2] as Command["result"] });
+ else errors.push(`cannot parse \`${line}\` β expected \`/done \``);
+}
+
+if (commands.length === 0 && errors.length === 0) {
+ console.log("no /done lines; nothing to do");
+ process.exit(0);
+}
+
+// ββ apply ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const state = await loadState();
+const catalog = await fetchCatalog(gh);
+const today = todayET();
+const mirror = await projectMirror(owner, gh.repo);
+const applied: string[] = [];
+
+const gate = isGate ? state.gates.find((g) => g.issue === input.issue) : undefined;
+if (isGate && !gate) errors.push(`no gate record for issue #${input.issue} in srs.json`);
+
+for (const cmd of commands) {
+ const known = catalog.problems.get(cmd.lc);
+ const entry = state.problems[cmd.lc];
+
+ if (!entry && !known) {
+ errors.push(`LC ${cmd.lc} is not in the curriculum`);
+ continue;
+ }
+
+ if (gate && known && gate.problems.includes(cmd.lc)) {
+ gate.results[cmd.lc] = cmd.result;
+ }
+
+ if (!entry) {
+ // First-ever attempt: enters the ladder at +2 regardless of result β a
+ // pass earns a +2 review, a fail must be re-solved just as soon.
+ const kind: Attempt["kind"] = gate ? "gate" : known!.set === "optional" ? "drill" : "first";
+ const p: (typeof state.problems)[string] = {
+ issue: known!.issue,
+ topic: known!.topic,
+ difficulty: known!.difficulty.toLowerCase(),
+ set: known!.set,
+ solved_on: today,
+ stage: "+2",
+ next_review: addDays(today, 2),
+ history: [{ date: today, kind, result: cmd.result }],
+ };
+ state.problems[cmd.lc] = p;
+ if (kind === "drill") {
+ if (!state.drill_pool_used.includes(cmd.lc)) state.drill_pool_used.push(cmd.lc);
+ if (cmd.result === "fail") {
+ const t = (state.topics[known!.topic] ??= { misses: 0, boost: false });
+ t.misses++;
+ applied.push(`LC ${cmd.lc}: recognition miss β topic #${known!.topic} misses=${t.misses}`);
+ }
+ }
+ if (!DRY) {
+ await mirror.setFirstAttempt(known!.issue, cmd.result);
+ await mirror.setStage(known!.issue, "+2");
+ await mirror.setTargetDate(known!.issue, p.next_review!);
+ if (cmd.result === "pass" && known!.open) {
+ await gh.closeIssue(
+ known!.issue,
+ `First attempt passed (${kind}) β entering the review ladder at +2. Logged by \`srs-logger\`.`,
+ );
+ }
+ }
+ applied.push(`LC ${cmd.lc}: first ${kind} ${cmd.result} β stage +2, review ${p.next_review}`);
+ continue;
+ }
+
+ // Already on the ladder: a review (or a gate/drill re-encounter).
+ if (entry.stage === "retired") {
+ errors.push(`LC ${cmd.lc} is already retired`);
+ continue;
+ }
+ entry.history.push({ date: today, kind: "review", result: cmd.result });
+ if (cmd.result === "pass") {
+ pass(entry, today);
+ } else {
+ fail(entry, today);
+ }
+ delete entry.defer_until; // once touched, it lives by next_review alone
+ if (!DRY) {
+ await mirror.setStage(entry.issue, entry.stage);
+ await mirror.setTargetDate(entry.issue, entry.next_review ?? null);
+ }
+ applied.push(
+ `LC ${cmd.lc}: review ${cmd.result} β stage ${entry.stage}` +
+ (entry.next_review ? `, review ${entry.next_review}` : " β retired π"),
+ );
+}
+
+// ββ persist + respond ββββββββββββββββββββββββββββββββββββββββββββ
+
+if (!DRY) await saveState(state);
+
+for (const line of applied) console.log(line);
+for (const line of errors) console.log(`error: ${line}`);
+
+if (!DRY && input.commentId) {
+ if (errors.length === 0) {
+ await gh.api(`/repos/${gh.repo}/issues/comments/${input.commentId}/reactions`, {
+ method: "POST",
+ body: JSON.stringify({ content: "+1" }),
+ });
+ } else {
+ await gh.api(`/repos/${gh.repo}/issues/${input.issue}/comments`, {
+ method: "POST",
+ body: JSON.stringify({
+ body: `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}${applied.length ? `\n\nApplied anyway:\n\n${applied.map((a) => `- ${a}`).join("\n")}` : ""}`,
+ }),
+ });
+ }
+}
+
+await reportMirror(mirror);
diff --git a/scripts/srs-project.ts b/scripts/srs-project.ts
new file mode 100644
index 0000000..da0c0f0
--- /dev/null
+++ b/scripts/srs-project.ts
@@ -0,0 +1,222 @@
+/**
+ * Best-effort mirror of SRS state into the user-level GitHub Project
+ * ("Interview Prep").
+ *
+ * The JSON state file is the truth; these fields only make the project's
+ * table and roadmap views double as a live review calendar. Every operation
+ * is wrapped: a failure records a warning and the run continues β a workflow
+ * must never lose an srs.json update because a GraphQL mutation failed.
+ *
+ * Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects),
+ * falling back to GH_TOKEN / `gh auth token` for local runs.
+ *
+ * Guardrail: only problem issues are ever passed in here, so topic rows'
+ * Target Date is never written.
+ */
+import { $ } from "bun";
+
+const PROJECT_TITLE = "Interview Prep";
+
+interface SelectField {
+ id: string;
+ options: Record; // option name -> option id
+}
+
+interface ProjectInfo {
+ id: string;
+ targetDate: string; // field id
+ srsStage: SelectField;
+ firstAttempt: SelectField;
+}
+
+export interface ProjectMirror {
+ /** Set (or clear, with null) a problem row's Target Date. */
+ setTargetDate(issue: number, date: string | null): Promise;
+ setStage(issue: number, stage: string): Promise;
+ setFirstAttempt(issue: number, result: string): Promise;
+ /** Accumulated failures; print + step-summarize these, never throw. */
+ warnings: string[];
+}
+
+async function resolveToken(): Promise {
+ const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
+ if (env) return env;
+ return (await $`gh auth token`.text()).trim();
+}
+
+export async function projectMirror(owner: string, repo: string): Promise {
+ const warnings: string[] = [];
+ let token = "";
+ let project: ProjectInfo | undefined;
+ const itemIds = new Map();
+
+ async function graphql(query: string, variables: Record): Promise {
+ const res = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
+ body: JSON.stringify({ query, variables }),
+ });
+ if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
+ const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
+ if (payload.errors?.length) {
+ throw new Error(payload.errors.map((e) => e.message).join("; "));
+ }
+ return payload.data;
+ }
+
+ /** Resolve project + field/option ids once; by name, never hardcoded. */
+ async function resolve(): Promise {
+ if (project) return project;
+ token ||= await resolveToken();
+ if (!token) throw new Error("no PROJECT_PAT / token available");
+
+ const data = (await graphql(
+ `query($owner: String!, $title: String!) {
+ user(login: $owner) {
+ projectsV2(first: 10, query: $title) {
+ nodes {
+ id title
+ fields(first: 30) {
+ nodes {
+ ... on ProjectV2FieldCommon { id name dataType }
+ ... on ProjectV2SingleSelectField { id name options { id name } }
+ }
+ }
+ }
+ }
+ }
+ }`,
+ { owner, title: PROJECT_TITLE },
+ )) as {
+ user: {
+ projectsV2: {
+ nodes: {
+ id: string;
+ title: string;
+ fields: {
+ nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
+ };
+ }[];
+ };
+ };
+ };
+
+ const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
+ if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
+
+ const select = (name: string): SelectField => {
+ const f = node.fields.nodes.find((n) => n.name === name);
+ if (!f?.options) throw new Error(`single-select field "${name}" missing β run srs-setup`);
+ return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
+ };
+ const date = node.fields.nodes.find((n) => n.name === "Target Date");
+ if (!date) throw new Error(`date field "Target Date" missing from project`);
+
+ project = {
+ id: node.id,
+ targetDate: date.id,
+ srsStage: select("SRS Stage"),
+ firstAttempt: select("First Attempt"),
+ };
+ return project;
+ }
+
+ /** The issue's item id in THIS project (an issue can be in several). */
+ async function itemId(issue: number): Promise {
+ if (itemIds.has(issue)) {
+ const cached = itemIds.get(issue);
+ if (!cached) throw new Error(`issue #${issue} is not in the project`);
+ return cached;
+ }
+ const info = await resolve();
+ const [repoOwner, repoName] = repo.split("/");
+ const data = (await graphql(
+ `query($owner: String!, $name: String!, $issue: Int!) {
+ repository(owner: $owner, name: $name) {
+ issue(number: $issue) {
+ projectItems(first: 10, includeArchived: true) {
+ nodes { id project { id } }
+ }
+ }
+ }
+ }`,
+ { owner: repoOwner, name: repoName, issue },
+ )) as {
+ repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
+ };
+ const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === info.id);
+ itemIds.set(issue, item?.id);
+ if (!item) throw new Error(`issue #${issue} is not in the project`);
+ return item.id;
+ }
+
+ async function setField(issue: number, fieldId: string, value: object): Promise {
+ const info = await resolve();
+ await graphql(
+ `mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
+ updateProjectV2ItemFieldValue(
+ input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
+ ) { projectV2Item { id } }
+ }`,
+ { project: info.id, item: await itemId(issue), field: fieldId, value },
+ );
+ }
+
+ async function clearField(issue: number, fieldId: string): Promise {
+ const info = await resolve();
+ await graphql(
+ `mutation($project: ID!, $item: ID!, $field: ID!) {
+ clearProjectV2ItemFieldValue(
+ input: { projectId: $project, itemId: $item, fieldId: $field }
+ ) { projectV2Item { id } }
+ }`,
+ { project: info.id, item: await itemId(issue), field: fieldId },
+ );
+ }
+
+ /** Run op; on failure record a warning instead of propagating. */
+ async function attempt(what: string, op: () => Promise): Promise {
+ try {
+ await op();
+ } catch (err) {
+ warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ return {
+ warnings,
+ setTargetDate: (issue, date) =>
+ attempt(`Target Date #${issue}`, async () => {
+ const info = await resolve();
+ if (date === null) await clearField(issue, info.targetDate);
+ else await setField(issue, info.targetDate, { date });
+ }),
+ setStage: (issue, stage) =>
+ attempt(`SRS Stage #${issue}`, async () => {
+ const info = await resolve();
+ const option = info.srsStage.options[stage];
+ if (!option) throw new Error(`no option "${stage}"`);
+ await setField(issue, info.srsStage.id, { singleSelectOptionId: option });
+ }),
+ setFirstAttempt: (issue, result) =>
+ attempt(`First Attempt #${issue}`, async () => {
+ const info = await resolve();
+ const option = info.firstAttempt.options[result];
+ if (!option) throw new Error(`no option "${result}"`);
+ await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option });
+ }),
+ };
+}
+
+/** Print mirror warnings and surface them in the Actions run summary. */
+export async function reportMirror(mirror: ProjectMirror): Promise {
+ if (mirror.warnings.length === 0) return;
+ console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) β srs.json is the truth`);
+ for (const w of mirror.warnings) console.warn(` β ${w}`);
+ if (process.env.GITHUB_STEP_SUMMARY) {
+ await Bun.write(
+ process.env.GITHUB_STEP_SUMMARY,
+ `### β project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`,
+ );
+ }
+}
diff --git a/scripts/srs-scheduler.ts b/scripts/srs-scheduler.ts
new file mode 100755
index 0000000..3a55cd8
--- /dev/null
+++ b/scripts/srs-scheduler.ts
@@ -0,0 +1,201 @@
+#!/usr/bin/env bun
+/**
+ * Rewrite the pinned `π Today` daily-brief issue from SRS state.
+ *
+ * Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design:
+ * it reads srs.json + schedule.json and rewrites one issue body, so running
+ * it twice in a day is a byte-identical no-op (drill picks are seeded by the
+ * date). Overflow needs no bookkeeping either β anything past the daily cap
+ * simply stays due and is the oldest entry tomorrow.
+ *
+ * Retrieval rules enforced here:
+ * - Review and drill lines carry number + difficulty only. Never the topic,
+ * never a link β recognizing the pattern unaided is the exercise.
+ * - Blind drills come only from optional pools of topics covered in EARLIER
+ * weeks; the current week's optional pool is reserved for Saturday's gate.
+ * - Boosted topics (failed gate problem / repeated drill misses) inject up to
+ * 2 extra drills, inside the same daily cap.
+ *
+ * bun scripts/srs-scheduler.ts # rewrite the live issue
+ * SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing
+ */
+import { github } from "./github.ts";
+import { projectMirror, reportMirror } from "./srs-project.ts";
+import {
+ CAMPAIGN_DAYS,
+ type Catalog,
+ type State,
+ TODAY_TITLE,
+ campaignDay,
+ campaignWeek,
+ dueReviews,
+ fetchCatalog,
+ loadSchedule,
+ loadState,
+ prettyDate,
+ rng,
+ sample,
+ todayET,
+ weekdayOf,
+} from "./srs.ts";
+
+const DRY = process.env.SRS_DRY === "1";
+const DAILY_CAP = 6;
+
+const gh = await github();
+const state = await loadState();
+const schedule = await loadSchedule();
+const today = todayET();
+const week = campaignWeek(today);
+
+// ββ compose the body βββββββββββββββββββββββββββββββββββββββββββββ
+
+/** Week in which a topic was (or will be) taught, from the schedule file. */
+function topicWeek(topic: number): number | undefined {
+ for (const [date, t] of Object.entries(schedule)) {
+ if (t === topic) return campaignWeek(date);
+ }
+ return undefined;
+}
+
+interface Drill {
+ lc: number;
+ difficulty: string;
+}
+
+function pickDrills(catalog: Catalog, budget: number): Drill[] {
+ // Optional problems from earlier weeks, unseen by both ladder and drills.
+ const pool = [...catalog.problems.values()]
+ .filter((p) => {
+ if (p.set !== "optional" || !p.open) return false;
+ if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false;
+ const w = topicWeek(p.topic);
+ return w !== undefined && w < week;
+ })
+ .sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle
+
+ const boosted = pool.filter((p) => state.topics[p.topic]?.boost);
+ const regular = pool.filter((p) => !state.topics[p.topic]?.boost);
+
+ const picks = [
+ ...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)),
+ ...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)),
+ ].slice(0, budget);
+ return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty }));
+}
+
+function composeBody(catalog: Catalog): string {
+ if (weekdayOf(today) === 0) {
+ return "Rest day. Nothing is due. Overdue reviews moved to Monday.";
+ }
+
+ const due = dueReviews(state, today);
+ const reviews = due.slice(0, DAILY_CAP);
+ const carried = due.length - reviews.length;
+ const drills = pickDrills(catalog, DAILY_CAP - reviews.length);
+
+ const lines: string[] = [
+ `## ${prettyDate(today)} β Day ${campaignDay(today)}/${CAMPAIGN_DAYS} Β· Week ${week}`,
+ "",
+ ];
+
+ const topicIssue = schedule[today];
+ const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue);
+ if (topic) {
+ const core = [...catalog.problems.values()]
+ .filter((p) => p.topic === topic.issue && p.set === "core")
+ .sort((a, b) => a.lc - b.lc)
+ .map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " β"}`);
+ lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" Β· ")}`, "");
+ } else {
+ lines.push("_No new topic today β reviews and drills only._", "");
+ }
+
+ lines.push(`### Reviews due (${reviews.length})`);
+ if (reviews.length) {
+ lines.push("Solve each from scratch. Do not open your old solution first.");
+ for (const [lc, p] of reviews) {
+ const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty;
+ lines.push(`- LC ${lc} β ${difficulty} β stage ${p.stage}`);
+ }
+ if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`);
+ } else {
+ lines.push("None.");
+ }
+ lines.push("");
+
+ lines.push(`### Blind drills (${drills.length})`);
+ if (drills.length) {
+ lines.push("No topic given. Name the pattern out loud before you code.");
+ for (const d of drills) lines.push(`- LC ${d.lc} β ${d.difficulty}`);
+ } else {
+ lines.push("None today.");
+ }
+ lines.push("");
+
+ lines.push(
+ "### Log your results",
+ "Comment on this issue, one line per problem:",
+ "`/done 704 pass` Β· `/done 15 fail`",
+ "",
+ "### Rules",
+ "90-minute cap Β· core first, then reviews, then drills Β· close-out",
+ "ritual on every submit.",
+ );
+ return lines.join("\n");
+}
+
+// ββ find / create / pin the Today issue ββββββββββββββββββββββββββ
+
+async function todayIssue(): Promise<{ number: number; created: boolean }> {
+ for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) {
+ const issue = raw as { number: number; title: string; pull_request?: unknown };
+ if (!issue.pull_request && issue.title === TODAY_TITLE) {
+ return { number: issue.number, created: false };
+ }
+ }
+ const created = (await gh.api(`/repos/${gh.repo}/issues`, {
+ method: "POST",
+ body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }),
+ })) as { number: number; node_id: string };
+ try {
+ await gh.api("/graphql", {
+ method: "POST",
+ body: JSON.stringify({
+ query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`,
+ variables: { id: created.node_id },
+ }),
+ });
+ } catch (err) {
+ console.warn(`could not pin #${created.number}: ${err} β pin it by hand`);
+ }
+ return { number: created.number, created: true };
+}
+
+// ββ run ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const catalog = await fetchCatalog(gh);
+const body = composeBody(catalog);
+
+if (DRY) {
+ console.log(body);
+ process.exit(0);
+}
+
+const issue = await todayIssue();
+await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, {
+ method: "PATCH",
+ body: JSON.stringify({ body }),
+});
+console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`);
+
+// Mirror every due problem's next_review so the project's Target Date views
+// stay a live review calendar (problem rows only β never topic rows).
+const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo);
+if (weekdayOf(today) !== 0) {
+ for (const [, p] of dueReviews(state as State, today)) {
+ await mirror.setTargetDate(p.issue, p.next_review!);
+ }
+}
+await reportMirror(mirror);
+console.log(body);
diff --git a/scripts/srs-setup.ts b/scripts/srs-setup.ts
new file mode 100755
index 0000000..901a5b0
--- /dev/null
+++ b/scripts/srs-setup.ts
@@ -0,0 +1,146 @@
+#!/usr/bin/env bun
+/**
+ * One-shot (idempotent) SRS setup + PROJECT_PAT verification.
+ *
+ * 1. Proves the token can talk GraphQL (viewer login) β the acceptance test
+ * for the PROJECT_PAT repo secret when dispatched as a workflow.
+ * 2. Resolves the "Interview Prep" user project and its built-in Target Date
+ * field (which the SRS reuses β no custom date field is ever created).
+ * 3. Creates the `SRS Stage` and `First Attempt` single-selects when missing.
+ * 4. Mirrors every laddered problem's state into the project fields.
+ *
+ * Prints every resolved ID so they are on record in the run log.
+ */
+import { $ } from "bun";
+
+import { github } from "./github.ts";
+import { projectMirror, reportMirror } from "./srs-project.ts";
+import { loadState } from "./srs.ts";
+
+const PROJECT_TITLE = "Interview Prep";
+
+const token =
+ process.env.PROJECT_PAT ||
+ process.env.GH_TOKEN ||
+ process.env.GITHUB_TOKEN ||
+ (await $`gh auth token`.text()).trim();
+
+async function graphql(query: string, variables: Record = {}): Promise {
+ const res = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
+ body: JSON.stringify({ query, variables }),
+ });
+ if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
+ const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
+ if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
+ return payload.data;
+}
+
+// 1. token check β hard fail here means PROJECT_PAT is missing or scopeless.
+const viewerData = (await graphql(`{ viewer { login } }`)) as { viewer: { login: string } };
+console.log(`token OK β authenticated as @${viewerData.viewer.login}`);
+
+const gh = await github();
+const owner = gh.repo.split("/")[0]!;
+
+// 2. resolve the project and its fields.
+interface FieldNode {
+ id: string;
+ name: string;
+ dataType?: string;
+ options?: { id: string; name: string }[];
+}
+const projectData = (await graphql(
+ `query($owner: String!, $title: String!) {
+ user(login: $owner) {
+ projectsV2(first: 10, query: $title) {
+ nodes {
+ id number title
+ fields(first: 30) {
+ nodes {
+ ... on ProjectV2FieldCommon { id name dataType }
+ ... on ProjectV2SingleSelectField { id name options { id name } }
+ }
+ }
+ }
+ }
+ }
+ }`,
+ { owner, title: PROJECT_TITLE },
+)) as {
+ user: {
+ projectsV2: { nodes: { id: string; number: number; title: string; fields: { nodes: FieldNode[] } }[] };
+ };
+};
+const project = projectData.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
+if (!project) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
+console.log(`project: "${project.title}" #${project.number} β ${project.id}`);
+
+let fields = project.fields.nodes;
+const targetDate = fields.find((f) => f.name === "Target Date" && f.dataType === "DATE");
+if (!targetDate) {
+ throw new Error('the built-in "Target Date" field is missing β add it in the project UI');
+}
+console.log(`Target Date field: ${targetDate.id} (reused, not created)`);
+
+// 3. create the two SRS single-selects when absent.
+const WANTED: Record = {
+ "SRS Stage": [
+ { name: "new", color: "GRAY", description: "not yet on the ladder" },
+ { name: "+2", color: "YELLOW", description: "review due 2 days after last solve" },
+ { name: "+5", color: "ORANGE", description: "review due 5 days after last solve" },
+ { name: "+10", color: "BLUE", description: "review due 10 days after last solve" },
+ { name: "retired", color: "GREEN", description: "passed all three stages" },
+ ],
+ "First Attempt": [
+ { name: "pass", color: "GREEN", description: "first timed solve passed" },
+ { name: "fail", color: "RED", description: "first timed solve failed" },
+ ],
+};
+
+for (const [name, options] of Object.entries(WANTED)) {
+ const existing = fields.find((f) => f.name === name);
+ if (existing) {
+ console.log(`${name} field: ${existing.id} (already exists)`);
+ continue;
+ }
+ const optionsArg = options
+ .map((o) => `{name:"${o.name}",color:${o.color},description:"${o.description}"}`)
+ .join(",");
+ const created = (await graphql(
+ `mutation($project: ID!, $name: String!) {
+ createProjectV2Field(input: {
+ projectId: $project, dataType: SINGLE_SELECT, name: $name,
+ singleSelectOptions: [${optionsArg}]
+ }) {
+ projectV2Field { ... on ProjectV2SingleSelectField { id name } }
+ }
+ }`,
+ { project: project.id, name },
+ )) as { createProjectV2Field: { projectV2Field: { id: string } } };
+ console.log(`${name} field: ${created.createProjectV2Field.projectV2Field.id} (created)`);
+ fields = [...fields, { id: created.createProjectV2Field.projectV2Field.id, name }];
+}
+
+// 4. mirror every laddered problem into the project.
+const state = await loadState();
+const mirror = await projectMirror(owner, gh.repo);
+let mirrored = 0;
+for (const [lc, p] of Object.entries(state.problems)) {
+ await mirror.setStage(p.issue, p.stage);
+ await mirror.setTargetDate(p.issue, p.stage === "retired" ? null : (p.next_review ?? null));
+ const first = p.history[0];
+ if (first) await mirror.setFirstAttempt(p.issue, first.result);
+ mirrored++;
+ if (mirrored % 10 === 0) console.log(` mirrored ${mirrored} problems⦠(last LC ${lc})`);
+}
+console.log(`mirrored ${mirrored} laddered problems into the project`);
+await reportMirror(mirror);
+
+if (process.env.GITHUB_STEP_SUMMARY && mirror.warnings.length === 0) {
+ await Bun.write(
+ process.env.GITHUB_STEP_SUMMARY,
+ `### srs-setup β
\n\n- token: @${viewerData.viewer.login}\n- project: ${project.id} (#${project.number})\n- Target Date: ${targetDate.id}\n- mirrored: ${mirrored} problems\n`,
+ );
+}
diff --git a/scripts/srs.ts b/scripts/srs.ts
new file mode 100644
index 0000000..156296f
--- /dev/null
+++ b/scripts/srs.ts
@@ -0,0 +1,300 @@
+/**
+ * Domain library for the spaced-repetition system (SRS).
+ *
+ * State model β `.github/srs/srs.json` is the single source of truth; the
+ * GitHub Project fields are a best-effort mirror (see srs-project.ts).
+ *
+ * The stage names the review a problem must pass NEXT: a problem at `+2` has
+ * a review due 2 days after its last clean solve. Passing advances
+ * new β +2 β +5 β +10 β retired; any failure resets to +2. `new` is reserved
+ * for deferred Hards that have never been solved β `defer_until` keeps them
+ * out of the queue until Sep 28.
+ *
+ * All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires
+ * at 10:00 UTC = 6 AM ET, and the campaign is lived in ET.
+ */
+import { join } from "node:path";
+
+import type { GitHub } from "./github.ts";
+
+const ROOT = join(import.meta.dir, "..");
+
+// ββ state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
+export type Result = "pass" | "fail";
+
+export interface Attempt {
+ date: string;
+ /** first = learning-day solve, drill = blind drill, gate = gate problem. */
+ kind: "first" | "review" | "drill" | "gate";
+ result: Result;
+}
+
+export interface ProblemState {
+ issue: number;
+ topic: number;
+ difficulty: string;
+ set: string;
+ solved_on?: string;
+ stage: Stage;
+ next_review?: string;
+ /** Deferred Hards stay out of the due queue until this date. */
+ defer_until?: string;
+ history: Attempt[];
+}
+
+export interface TopicState {
+ /** Blind-drill recognition misses since the last boost reset. */
+ misses: number;
+ /** Set at gate close; scheduler injects extra drills; next gate clears it. */
+ boost: boolean;
+}
+
+export interface GateState {
+ week: number;
+ issue: number;
+ date: string;
+ problems: number[];
+ /** Problems that fell back to the core set (optional pool exhausted). */
+ fallbacks: number[];
+ results: Record;
+ rate?: number;
+}
+
+export interface State {
+ problems: Record;
+ topics: Record;
+ drill_pool_used: number[];
+ gates: GateState[];
+}
+
+export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json");
+export const SCHEDULE_PATH =
+ process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json");
+
+export async function loadState(): Promise {
+ return JSON.parse(await Bun.file(STATE_PATH).text());
+}
+
+export async function saveState(state: State): Promise {
+ await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
+}
+
+/** Day (YYYY-MM-DD) β topic issue number. Human-edited when life happens. */
+export async function loadSchedule(): Promise> {
+ return JSON.parse(await Bun.file(SCHEDULE_PATH).text());
+}
+
+// ββ dates (America/New_York) βββββββββββββββββββββββββββββββββββββ
+
+/** Campaign day 1 β Monday of Phase I week 1. Day 56 = Oct 11. */
+export const CAMPAIGN_START = "2026-08-17";
+export const CAMPAIGN_DAYS = 56;
+
+/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */
+export function todayET(): string {
+ if (process.env.SRS_TODAY) return process.env.SRS_TODAY;
+ return new Intl.DateTimeFormat("en-CA", {
+ timeZone: "America/New_York",
+ dateStyle: "short",
+ }).format(new Date());
+}
+
+/** Noon UTC anchor: date-only arithmetic immune to DST edges. */
+function atNoon(date: string): Date {
+ return new Date(`${date}T12:00:00Z`);
+}
+
+export function addDays(date: string, days: number): string {
+ return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
+}
+
+export function daysBetween(from: string, to: string): number {
+ return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
+}
+
+/** 0 = Sunday β¦ 6 = Saturday. */
+export function weekdayOf(date: string): number {
+ return atNoon(date).getUTCDay();
+}
+
+/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */
+export function campaignDay(date: string): number {
+ return daysBetween(CAMPAIGN_START, date) + 1;
+}
+
+/** 1-based campaign week (MonβSun), aligned to CAMPAIGN_START. */
+export function campaignWeek(date: string): number {
+ return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
+}
+
+export function isoWeek(date: string): number {
+ const d = atNoon(date);
+ // ISO 8601: week containing the year's first Thursday is week 1.
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+ const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+ return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
+}
+
+export function prettyDate(date: string): string {
+ return new Intl.DateTimeFormat("en-US", {
+ timeZone: "UTC",
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ }).format(atNoon(date));
+}
+
+// ββ the interval ladder ββββββββββββββββββββββββββββββββββββββββββ
+
+export const INTERVAL: Record = { "+2": 2, "+5": 5, "+10": 10 };
+const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
+
+/** Advance on a pass; returns false when the problem was already retired. */
+export function pass(p: ProblemState, today: string): boolean {
+ const next = NEXT_STAGE[p.stage];
+ if (!next) return false;
+ p.stage = next;
+ p.solved_on = today;
+ if (next === "retired") {
+ delete p.next_review;
+ } else {
+ p.next_review = addDays(today, INTERVAL[next]!);
+ }
+ return true;
+}
+
+/** Reset on a failure: back to the +2 stage, due again in 2 days. */
+export function fail(p: ProblemState, today: string): void {
+ p.stage = "+2";
+ p.next_review = addDays(today, 2);
+}
+
+/** Reviews due today or earlier, oldest first β the overflow carry order. */
+export function dueReviews(state: State, today: string): [string, ProblemState][] {
+ return Object.entries(state.problems)
+ .filter(
+ ([, p]) =>
+ p.stage !== "retired" &&
+ p.next_review !== undefined &&
+ p.next_review <= today &&
+ (p.defer_until === undefined || p.defer_until <= today),
+ )
+ .sort(([a, pa], [b, pb]) =>
+ pa.next_review === pb.next_review
+ ? Number(a) - Number(b)
+ : pa.next_review! < pb.next_review!
+ ? -1
+ : 1,
+ );
+}
+
+// ββ deterministic sampling βββββββββββββββββββββββββββββββββββββββ
+
+/** FNV-1a β mulberry32: a seeded PRNG so re-runs pick identical problems. */
+export function rng(seed: string): () => number {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < seed.length; i++) {
+ h ^= seed.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return () => {
+ h = Math.imul(h ^ (h >>> 15), h | 1);
+ h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
+ return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** Take up to n elements, FisherβYates order driven by the seeded PRNG. */
+export function sample(pool: T[], n: number, random: () => number): T[] {
+ const copy = [...pool];
+ for (let i = copy.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [copy[i], copy[j]] = [copy[j]!, copy[i]!];
+ }
+ return copy.slice(0, n);
+}
+
+// ββ the live catalog (topics + problem sub-issues) βββββββββββββββ
+
+export interface CatalogProblem {
+ lc: number;
+ issue: number;
+ name: string;
+ difficulty: "Easy" | "Medium" | "Hard";
+ set: "core" | "optional" | "deferred";
+ topic: number;
+ open: boolean;
+ url: string;
+}
+
+export interface CatalogTopic {
+ issue: number;
+ /** e.g. "Topic 03 β Two Pointers". */
+ title: string;
+ /** Bare name, e.g. "Two Pointers". */
+ name: string;
+ milestone: number | undefined;
+}
+
+export interface Catalog {
+ topics: Map;
+ problems: Map;
+ byIssue: Map;
+}
+
+const TITLE_RE = /^LC (\d+) Β· (.+) Β· (Easy|Medium|Hard) Β· (core|optional|deferred)$/;
+
+/**
+ * Walk every `topic` issue and its sub-issues into one lookup structure.
+ * The sub-issue linkage is authoritative: every problem hangs off exactly
+ * one topic (verified against the full issue inventory).
+ */
+export async function fetchCatalog(gh: GitHub): Promise {
+ const topics = new Map();
+ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
+ const t = raw as {
+ number: number;
+ title: string;
+ pull_request?: unknown;
+ milestone?: { number: number } | null;
+ };
+ if (t.pull_request) continue;
+ const name = t.title.replace(/^Topic \d+ β /, "");
+ topics.set(t.number, {
+ issue: t.number,
+ title: t.title,
+ name,
+ milestone: t.milestone?.number,
+ });
+ }
+
+ const problems = new Map();
+ const byIssue = new Map();
+ for (const topic of topics.keys()) {
+ for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
+ const s = raw as { number: number; title: string; state: string; body?: string };
+ const m = s.title.match(TITLE_RE);
+ if (!m) continue; // non-curriculum sub-issue
+ const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0];
+ const p: CatalogProblem = {
+ lc: Number(m[1]),
+ issue: s.number,
+ name: m[2]!,
+ difficulty: m[3] as CatalogProblem["difficulty"],
+ set: m[4] as CatalogProblem["set"],
+ topic,
+ open: s.state === "open",
+ url: url ?? "",
+ };
+ problems.set(p.lc, p);
+ byIssue.set(p.issue, p);
+ }
+ }
+ return { topics, problems, byIssue };
+}
+
+// ββ shared constants βββββββββββββββββββββββββββββββββββββββββββββ
+
+export const TODAY_TITLE = "π Today";