🎓 Classroom — HQ cockpit
The classroom infrastructure, from zero to ready. The cockpit guides what only a
human can click, checks it happened, and does the rest. A session is a private
hub repo + a team of the same name; adding a learner to a session grants that
team — never blanket org access. Full model in hq/RUNNER_DESIGN_20260718.md.
Sessions & roster
Unlocks once the cockpit above is green. The roster shows all members until you click a session in the top grid — then it narrows to that session. Create / delete a session, add / remove a member via the buttons.
How membership works — one gesture. You manage people per session, never org-wide. “Add member” puts a learner on that session’s team; if they aren’t in the org yet, GitHub sends them an org invite automatically — you never add them to the org by hand. Until they accept they show ⏳ invited; once accepted they are an org member and on the session team, showing ✅ team. So the flow is always: add to a session → (auto org-invite if needed) → accept → on the team.
📋 Invitations — what to press, and where
Every one of these starts the same way: open this page, and click the session row in the top grid (Session · Benches · Team). The member buttons under the roster only wake up once a session is selected.
A student who already has a GitHub account
- Click ➕ Add member.
- Type their GitHub username (no
@), press Add. - They appear as ⏳ invited, and as ✅ team once they accept.
A student with no GitHub account yet — the Canvas case
- Click ✉️ Invite by email.
- Paste their address — one per line for a whole group — press Send.
- Tell them the two things that decide whether it works: sign up with that same address, and do it within 7 days.
- They appear as ⏳ invited · 0d.
They say they never got it
- Tell them first: they do not need the mail. Sign in to GitHub, open
github.com/orgs/<your-org>/invitation, and it is there to accept. - Still stuck? Click their ⏳ invited row in the roster.
- Click 🔁 Re-send this one (the button renames itself once a waiting row is selected), press Send again. Fresh mail, and the 7 days restart.
Several have gone quiet, or the week is over
- Select the session, click nothing in the roster.
- Click 🔁 Nudge pending. It lists everyone still waiting with their age, and marks the ones past 7 days as lapsed.
- Press Send again. Only those people get mail — anyone who already joined is a member, not a pending invitation, so they are not in that list at all.
Someone dropped the class
- Still waiting (⏳ invited): select their row → ➖ Cancel invitation.
- Already joined (✅ team): select their row → ➖ Remove member. Their bench fork stays theirs; the access is what goes.
The whole Canvas roster at once — no typing, no command line
- Once, before the first run: put the Canvas course number in the course’s
__course.yml(canvas_course: …, beside its vault line), and the two secrets in the lab repo (Settings → Secrets and variables → Actions):CANVAS_TOKEN(Canvas → Account → Settings → + New Access Token) and the existingORG_VAULT_TOKEN, whose grant must also cover Organization permissions → Members: read and write. - Click 🎓 Roster invite, keep Plan, press Run. Nothing is sent.
- Keep the dialog open: the verdict — who would be invited, who is skipped and why, timestamped — arrives right there when the run finishes (about a minute).
- Happy? Click 🎓 Roster invite again, pick Invite, press Run — the send report arrives the same way. Members and pending invitations are skipped, so re-running after add/drop only fills the gaps — nobody gets mailed twice.
The roster passes through the runner’s memory and is gone — nothing is copied
into any file. (tools/course_invite.py is the same machinery from a
terminal, if you ever want it.)
A quiz into Canvas — same pattern, same keys
- Draft and lint the spec on the quiz desk until it shows no tells — a guessable or greppable key refuses to travel.
- Click 📝 Quiz → Canvas here, check the spec path, press Push — the verdict (lint, write, comment counts) arrives in the dialog.
- Open the quiz in Canvas — it arrives unpublished, and is published there, by you, after you read it.
A learner can only fork the (private) hub once they’re on the team, so a fork always implies access was granted at some point. If someone shows ⚠️ removed, they were on the team, forked their bench, then were removed — their fork lingers but access is gone.
members of the selected session
📊 Gradebook
📊 Gradebook reads every bench’s own __progress.txt — quizzes answered,
features turned green — and shows it here. Press it again to keep the
reading: it appends a stamped line per learner to
<org>-vault/gradebook/<session>.csv, a repo learners read and cannot
write. That series is the record; their file is their working copy.
Two flags come back with each reading. ✍️ hand-edited means the last
commit on that file was made through github.com (committer GitHub), not
by the app with the learner’s own key. ⚠️ crc means the file’s checksum
stopped matching. Neither blocks anything — it is their repo — but neither
is silent, and a jump from nothing to everything between two readings is
visible in the series.
Feature: The classroom console drives sessions with master–detail grids
As an educator
I want one console over the platform's own grids
So that selecting a session shows its members, with modal actions
Scenario: The console and its two grids render
Given the classroom page I am on
:::python
self.console: list = Object._all(".lc-console, .lc-con-bar")
self.grids: list = Object._all(".lc-datagrid")
:::
When the engine has loaded
Then the action bars and both master and detail grids are present
:::python
assert self.console, "console controls missing"
assert len(self.grids) >= 2, len(self.grids)
:::
🖼️ Framing a course page in Canvas
Two flags decide what a learner gets inside an LMS iframe. They ride every same-origin hop, so the scope you set is the scope they stay in.
| Flag | What it does |
|---|---|
?crumb=BUILD-AI |
The bar stops being a menu: one read-only line — course › module · page — plus the learner’s own avatar. No home link, no menu, no start pill, and the runner drops the file-path chip |
?up=0 |
The folder’s ⬆️ Up pill disappears, so a frame scoped to one module has no door out of it |
?strict=1 |
Every prerequisite in the frame also asks for the pages’ proofs, not only their points — see below |
Both are optional and independent:
scoped to one module /run.html?crumb=BUILD-AI&up=0#src=gh:<vault>/courses/micro_build_ai/module_06/index.md
open to the course /run.html?crumb=BUILD-AI#src=gh:<vault>/courses/micro_build_ai/index.md
The module’s title in the crumb comes from that module’s own index.md, read
once per session — never the folder name. Both flags ride every hop, so a
learner clicking from the course cover into module 6 stays in the same scope;
and in crumb mode the account chip is a statement, not a menu — no dropdown.
🔒 Gating a page on the proofs, not only the points
A prerequisite opens on what the learner earned on the page before. Add
features="true" and it also asks whether that page’s proofs are green
in their own record — the stricter reading of “done”, so nobody builds on a
lesson they only read.
Two levels, and the local one wins:
| Where | What it does |
|---|---|
?strict=1 on the frame’s URL |
every prerequisite in that scope asks for the proofs — a whole course walked the strict way, with no page edited |
{: .prerequisite features="true" } |
this gate asks for the proofs, wherever it is |
{: .prerequisite features="false" } |
this gate asks for the points only, even inside ?strict=1 |
{: .prerequisite features="true" pass="50" } |
half the points, and all the proofs |
Where the page can be read — a course page rendered from a vault — the count is against the features it declares, so running one proof and skipping three is not passing. A strict gate stays closed while it asks: it never shows the page and then takes it back.
Use it where the next page builds directly on a working artefact. Leave it off elsewhere: a gate nobody can open teaches that gates are decoration.