Learn while Claude works: short facts and yes/no questions in the band above the prompt, picked from what Claude is doing, paused whenever Claude needs you.

Learn something while Claude works.
When you send Claude Code a prompt, you often wait 30 seconds to a few minutes. Holdtime fills that wait with one short card at a time — a quick fact or a yes/no question — in the band just above your prompt. It picks cards from what Claude is doing right now, steps aside the moment Claude needs you, and remembers what you got wrong so it can ask again later.
It ships with cards for JavaScript, Python and Git, and writes its own for whatever else you are working on, so a Rust or Kubernetes session gets Rust or Kubernetes cards.
Holdtime · Git · yes or no? 1: Yes 2: No 9: Hide
Does `git rebase` give the rebased commits new hashes?
When Claude finishes, one line under its answer tells you how it went:
Holdtime: 3 cards this turn, 2/2 right · 7/20 today
Waiting on an AI agent creates many short breaks, and most of them end up on a phone. Holdtime keeps the break short and useful:
.py files or running pytest brings Python cards; git commands bring Git cards; cargo brings Rust and kubectl brings Kubernetes. Even the first card fits: a pyproject.toml, package.json or Cargo.toml in the folder sets the starting topic.claude --version.claude plugin marketplace add ItsRohith-A/holdtime
claude plugin install holdtime@holdtime
Restart Claude Code. The next time you give Claude a task, a card appears above the prompt.
To try a local copy instead:
git clone https://github.com/ItsRohith-A/holdtime.git
claude --plugin-dir ./holdtime
| Key | What it does |
|---|---|
1 | Yes, or "Got it" on a fact, or "Next" after an answer |
2 | No |
9 | Hide cards for the rest of this turn |
Type the digit on its own in the empty prompt; it answers the card instead of starting a prompt. You can also click the buttons, or focus the band with Ctrl+X then Tab.
| Command | What it does |
|---|---|
/holdtime | Today's count, your accuracy, and accuracy by topic |
/holdtime pause | Turn cards off (stays off across sessions) |
/holdtime resume | Turn them back on |
/holdtime reset | Delete your saved progress and start fresh |
Change these in /config, or when you install:
| Setting | Default | What it does |
|---|---|---|
| Write new cards with a model | On | Cards for topics beyond the three packs. Uses your Claude plan; see Privacy |
| Daily goal | 20 | Cards per day before Holdtime goes quiet |
| Cards per turn | 5 | Most cards in one Claude turn |
108 cards ship with Holdtime, in three topics, half quick facts and half yes/no questions:
| Topic | Cards | Picked when Claude… |
|---|---|---|
| JavaScript and TypeScript | 36 | edits .js/.ts files, runs npm, node, tsc… |
| Python | 36 | edits .py files, runs python, pytest, pip… |
| Git | 36 | runs git or gh commands |
With no signal yet, all three topics are mixed. Every shipped card was written for this project; none are copied from other sites.
Holdtime recognises Rust, Go, Ruby, Java, Kotlin, Swift, PHP, C#, SQL, Docker, Kubernetes, Terraform, shell and CSS from the files Claude edits and the commands it runs. No pack covers those, so Holdtime asks Claude to write cards for them in the background and keeps the good ones.
A generated card has to clear the same bar as a shipped one — one line, a question that ends in ?, a real explanation — or it is thrown away unread. The shipped packs fill in whenever the library is empty, the request fails, or you are offline, so the band is never blank waiting on a model.
This is on by default. It uses your Claude plan and sends the topic name to Claude; nothing from your code or conversation goes with it. Turn it off in /config and Holdtime makes no network requests at all. See PRIVACY.md.
Holdtime collects no personal data and sends the author nothing. It reads only the name of each tool Claude uses, the file extension, and the first word of a shell command, to pick a topic. Your progress is kept in Claude Code's local plugin store, and /holdtime reset deletes it.
One thing leaves your computer, and only while card writing is on: the topic name, sent to Claude on your own plan so it can write cards about it. Never your code, your prompts or Claude's replies. Turn Write new cards with a model off in /config and Holdtime makes no network requests at all. See PRIVACY.md.
A mod sits between Claude Code and what it is about to do, so it is fair to ask what each hook sees and what it changes. Holdtime handles eight events. Two of them change anything at all, and both change only Holdtime's own output:
| Event | What it reads | What it changes |
|---|---|---|
session.start | Whether a few project files exist, to pick the first topic | Nothing. Registers /holdtime and loads your saved progress |
command.run | Only /holdtime: a matcher limits the hook to that one command, so no other command reaches it | Nothing. It answers its own command with the text you see |
turn.start | That a turn began | Nothing. Picks the first card |
tool.call | The tool's name, a file's extension, the first word of a shell command | Nothing. The call and its result pass through untouched; it never denies, delays or alters a tool call |
classic.PermissionRequest | The tool's name and the one field identifying the call | Nothing — see below |
classic.Notification | That Claude has asked you something | Nothing. Passes the notification on untouched |
turn.complete | The turn's answer and how many cards you saw | Adds one line beneath Claude's answer, the Holdtime: 2 cards this turn… summary. Claude's answer itself is untouched |
ui.render | The band's width, and whether Claude is working | Draws the card in the band above the prompt. It leaves every other part of the screen alone |
command.run and tool.call are events that are also the names of calls, so a hook on either could in principle watch or change such a call made by other code. Holdtime's command.run hook carries the matcher { command: 'holdtime' }, so it is never offered another plugin's command, and its tool.call hook returns exactly what next(e) gave it.
Holdtime reads no file contents, no prompts and none of Claude's replies, and cannot: the only file call it makes is $.fs.exists. Besides the band, the one other thing it puts on screen is a toast the first time you reach the daily goal. Run claude plugin validate .claude-plugin/plugin.json on a clone to see this list of events, and every call the mod makes, printed from the source.
Holdtime handles classic.PermissionRequest, the event Claude Code raises when it asks you to approve a tool call. It is worth being plain about this one, because a hook on that event could answer for you. Holdtime's does not.
It never allows, denies, or changes a request, and it never answers on your behalf. It takes no decision under any condition: there is no branch in it, no setting that changes it, and no input that makes it behave differently. It reads two things — the tool's name, and the single field that identifies the call, such as the command, the path or the URL — writes them to a variable in memory, and passes the request on unchanged. Every path ends in return next(e), so the permission prompt you see is exactly the one Claude Code would have shown without Holdtime installed.
It exists for one reason: the moment Claude needs you, the card leaves the band, so the permission prompt has your whole attention instead of competing with a quiz. The card comes back when the approved call finishes, or when Claude starts new work. What it noted is never written to disk and never sent anywhere.
The hook is four lines, at the end of register in hooks/register.tsx:
on('classic.PermissionRequest', async ($, e, next) => {
await waitForYou($, e.tool_name, keyOf((e.tool_input ?? {}) as Readonly<Record<string, unknown>>))
return next(e)
})
Holdtime's hook on classic.Notification works the same way and for the same reason: it notices that Claude has asked you something, hides the card, and passes the notification on untouched.
No cards appear. Check claude --version is 2.1.287 or newer, that the plugin is enabled in /plugin, and that /holdtime does not say "paused". Cards only show while Claude is working, and not once today's goal is reached.
Only JavaScript, Python and Git cards. Those are the shipped packs. Cards for other topics are written in the background and take a few seconds to arrive; /holdtime shows how many are ready. If it always says 0 generated, check Write new cards with a model is on in /config.
Typing 1 started a prompt instead of answering. The digit must be the only thing in an empty prompt. Otherwise, click the button or focus the band with Ctrl+X then Tab.
A card disappeared mid-question. Claude asked for permission or a decision. Answer Claude; the card returns when it continues.
claude plugin validate .claude-plugin/plugin.json # manifest and hooks module
claude plugin test . # the test suite
claude --plugin-dir . # run your working copy
For editor type checking, run /plugin-types once inside Claude Code from this folder. It writes Claude Code's API types to .claude/types/, which tsconfig.json reads and git ignores. Then npx tsc -p tsconfig.json checks the code and the tests.
hooks/register.tsx events and the band: what Holdtime does in Claude Code
hooks/planner.ts pure logic: topics, card choice, spaced repetition
hooks/pool.ts pure logic: the generated library and when to refill it
content/*.ts the card packs
content/generate.ts the prompt, and turning a model's reply into cards
types/index.d.ts the shapes of cards and of the band's state
A new shipped card is one object in content/<topic>.ts. Both shipped and generated cards go through isValidCard in planner.ts, so the tests and the runtime filter agree on what a card has to be: a unique id, one line, a question that ends with ?, an answer and an explanation.
Nothing calls a model in the tests. $.model.complete is an event like any other, so band.test.tsx answers it with a canned reply.
Raise version in .claude-plugin/plugin.json for every release so installed copies update.
MIT — see LICENSE.
hooks/register.tsx 472 lines1/**
2 * Holdtime: short learning cards in the band above the prompt while Claude
3 * works. A card appears when a turn starts, steps aside whenever Claude needs
4 * the person, and the turn ends with a one-line summary under Claude's answer.
5 *
6 * The decisions (which card, how an answer is graded) live in planner.ts;
7 * this module connects them to Claude Code's events and draws the band.
8 */
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { Card, Feedback, Topic, TurnTally } from '../types'
13import { CARDS } from '../content'
14import { BATCH_TOKENS, buildPrompt, MODEL, parseCards, SYSTEM, TIMEOUT_MS } from '../content/generate'
15import {
16 accuracyByTopic, bump, dayOf, decay, freshWeights, grade, isAnswered, keyOf, labelOf, pickCard,
17 PROJECT_FILES, seed, topicOf,
18} from './planner'
19import type { Ask, Progress, Weights } from './planner'
20import { avoidFor, merge, needsRefill, readLibrary, topicToFill } from './pool'
21
22const cardAtom = atom({ plugin: 'holdtime', key: 'card' } as const, null)
23const feedbackAtom = atom({ plugin: 'holdtime', key: 'feedback' } as const, null)
24const needsYouAtom = atom({ plugin: 'holdtime', key: 'needsYou' } as const, false)
25const tallyAtom = atom({ plugin: 'holdtime', key: 'tally' } as const, { seen: 0, asked: 0, right: 0 })
26
27/** How long after a topic goes quiet before its cards are generated. */
28const REFILL_DELAY_MS = 1500
29
30/**
31 * How long before the same topic may be asked about again. A model that
32 * returns few usable cards leaves the topic short, so without this the next
33 * tool call would queue another paid request, and so would the one after it.
34 */
35const RETRY_MS = 5 * 60_000
36
37/** Notifications that mean Claude is waiting on the person. */
38const NEEDS_YOU = new Set(['permission_prompt', 'agent_needs_input', 'elicitation_dialog', 'elicitation_url_dialog'])
39
40type DayCount = { date: string; count: number }
41
42/**
43 * The session's bookkeeping, set up again on every load. Progress lives in
44 * $.store, which every Claude Code session on the machine shares, so it is
45 * read again right before each write; what the band draws lives in $.state.
46 */
47const session = {
48 dailyGoal: 20,
49 maxPerTurn: 5,
50 weights: freshWeights() as Weights,
51 progress: {} as Progress,
52 isPaused: false,
53 isHiddenThisTurn: false,
54 /** Cards shown this session, never offered again until the next one. */
55 shown: new Set<string>(),
56 /** The card on screen that has not been answered yet. */
57 unanswered: null as string | null,
58 /** What Claude is waiting on the person for, while it waits. */
59 ask: null as Ask | null,
60 /**
61 * Set while a press is being handled. Checked and set before anything
62 * awaits, so a second press of the same key cannot count twice.
63 */
64 isBusy: false,
65 /** Whether cards may be generated. Off makes Holdtime pack-only and offline. */
66 isAiOn: true,
67 /** Generated cards, shared with every session through $.store. */
68 library: [] as Card[],
69 /** Set while a batch is being generated, so only one is in flight. */
70 isGenerating: false,
71 /** The pending background refill, cancelled when a newer topic arrives. */
72 refill: null as { cancel: () => void } | null,
73 /** When each topic was last asked about, so a short reply cannot loop. */
74 triedAt: {} as Record<Topic, number>,
75}
76
77/** Everything that can be shown: the shipped packs and what a model wrote. */
78function allCards(): readonly Card[] {
79 return session.library.length === 0 ? CARDS : [...CARDS, ...session.library]
80}
81
82/** Takes the press lock; false when another press is still being handled. */
83function lock(): boolean {
84 if (session.isBusy) return false
85 session.isBusy = true
86 return true
87}
88
89function unlock(): void {
90 session.isBusy = false
91}
92
93async function today($: EngineInterface): Promise<string> {
94 return dayOf(await $.clock.now())
95}
96
97async function readDay($: EngineInterface): Promise<DayCount> {
98 const stored = (await $.store.get('day')) as DayCount | undefined
99 const date = await today($)
100 return stored && stored.date === date ? stored : { date, count: 0 }
101}
102
103/**
104 * Asks a model for a batch of cards on one topic and keeps the ones fit to
105 * show. Runs off a timer, never on the path between a turn starting and the
106 * first card, so a slow or failed request costs the person nothing but a
107 * pack card instead of a generated one. Time inside $.model.complete does
108 * not count against a hook's limit, so there is no rush here.
109 */
110async function generate($: EngineInterface, topic: Topic): Promise<void> {
111 if (!session.isAiOn || session.isGenerating) return
112 session.isGenerating = true
113 try {
114 // Stamped here, where the request is really made, rather than where it is
115 // queued: a queued refill can be cancelled by a newer topic, and that
116 // topic was never asked about, so it must not be held back.
117 session.triedAt[topic] = await $.clock.now()
118 const reply = await $.model.complete({
119 model: MODEL,
120 system: SYSTEM,
121 prompt: buildPrompt(topic, avoidFor(session.library, CARDS, topic)),
122 maxTokens: BATCH_TOKENS,
123 timeoutMs: TIMEOUT_MS,
124 })
125 // A Claude API failure resolves rather than rejects, so this is the check.
126 if (!reply.isAnswered) return
127 const made = parseCards(topic, reply.text)
128 if (made.length === 0) return
129 // Another session may have generated since this one last read.
130 const stored = readLibrary(await $.store.get('library'))
131 session.library = merge(stored, made)
132 await $.store.set('library', session.library)
133 } catch {
134 // A model the organization blocks, or no credentials: stay on the packs.
135 } finally {
136 session.isGenerating = false
137 }
138}
139
140/**
141 * Queues a refill for the topic the session leans towards, if that topic is
142 * running short. Called as tool calls come in, so it is debounced: only the
143 * last topic of a burst is generated for.
144 */
145async function queueRefill($: EngineInterface): Promise<void> {
146 if (!session.isAiOn || session.isGenerating) return
147 const topic = topicToFill(session.weights)
148 if (!needsRefill(session.library, topic, session.shown)) return
149 const tried = session.triedAt[topic]
150 if (tried !== undefined && (await $.clock.now()) - tried < RETRY_MS) return
151 session.refill?.cancel()
152 // Awaited because the mods API may hand back the timer through a promise;
153 // awaiting a plain timer is harmless either way.
154 session.refill = await $.clock.after(REFILL_DELAY_MS, () => generate($, topic))
155}
156
157/** Shows the next card, or nothing when a cap is reached or learning is off. */
158async function showNext($: EngineInterface): Promise<void> {
159 const tally = await read($, tallyAtom)
160 const day = await readDay($)
161 const isCapped = tally.seen >= session.maxPerTurn || day.count >= session.dailyGoal
162 const isOff = session.isPaused || session.isHiddenThisTurn || isCapped
163 const card = isOff ? null : pickCard(allCards(), session.weights, session.progress, day.date, session.shown)
164 if (card) session.shown.add(card.id)
165 session.unanswered = card?.id ?? null
166 await update($, feedbackAtom, () => null)
167 await update($, cardAtom, () => card)
168}
169
170/**
171 * Records a seen card: grades it on top of what the store holds now (another
172 * session may have written since), counts it for today, and saves both.
173 */
174async function record($: EngineInterface, card: Card, isRight: boolean | null): Promise<void> {
175 const date = await today($)
176 const stored = ((await $.store.get('progress')) as Progress | undefined) ?? {}
177 session.progress = grade(stored, card, isRight, date)
178 await $.store.set('progress', session.progress)
179
180 // Read the count as late as possible, so the gap between reading it and
181 // writing it back is as short as $.store allows. There is no atomic
182 // increment, so two sessions answering at the same moment can still lose a
183 // count; the cap it feeds is a soft one, so that is acceptable.
184 const day = await readDay($)
185 const counted: DayCount = { date: day.date, count: day.count + 1 }
186 await $.store.set('day', counted)
187
188 session.unanswered = null
189 await update($, tallyAtom, t => ({
190 seen: t.seen + 1,
191 asked: t.asked + (isRight === null ? 0 : 1),
192 right: t.right + (isRight === true ? 1 : 0),
193 }))
194 // Toast on the card that carries the count over the goal, not on an exact
195 // match: another session's write can take it past the goal in one step.
196 if (day.count < session.dailyGoal && counted.count >= session.dailyGoal) {
197 $.ui.toast(`Holdtime: daily goal of ${session.dailyGoal} cards reached. Nice work.`)
198 }
199}
200
201/** True while `card` is still the one on screen and not yet answered. */
202async function isCurrent($: EngineInterface, card: Card): Promise<boolean> {
203 const shownCard = await read($, cardAtom)
204 return shownCard?.id === card.id && (await read($, feedbackAtom)) === null
205}
206
207// A second press can arrive before the band redraws: each handler takes the
208// lock before it awaits anything, and checks the card it was drawn for is
209// still the one on screen.
210
211async function answer($: EngineInterface, card: Card, said: boolean): Promise<void> {
212 if (!lock()) return
213 try {
214 if (!(await isCurrent($, card))) return
215 const isRight = said === card.answer
216 const feedback: Feedback = { isRight, text: card.explain ?? '' }
217 await update($, feedbackAtom, () => feedback)
218 await record($, card, isRight)
219 } finally {
220 unlock()
221 }
222}
223
224async function moveOn($: EngineInterface, card: Card): Promise<void> {
225 if (!lock()) return
226 try {
227 if ((await read($, cardAtom))?.id !== card.id) return
228 if (session.unanswered === card.id) await record($, card, null)
229 await showNext($)
230 } finally {
231 unlock()
232 }
233}
234
235async function hide($: EngineInterface): Promise<void> {
236 if (!lock()) return
237 try {
238 session.isHiddenThisTurn = true
239 forgetUnanswered()
240 await update($, cardAtom, () => null)
241 } finally {
242 unlock()
243 }
244}
245
246/** A card the person never answered may come back later in the session. */
247function forgetUnanswered(): void {
248 if (session.unanswered) session.shown.delete(session.unanswered)
249 session.unanswered = null
250}
251
252async function statsText($: EngineInterface): Promise<string> {
253 const day = await readDay($)
254 const byTopic = accuracyByTopic(allCards(), session.progress)
255 const rows = Object.entries(byTopic).sort((a, b) => b[1].asked - a[1].asked)
256 const asked = rows.reduce((n, [, row]) => n + row.asked, 0)
257 const right = rows.reduce((n, [, row]) => n + row.right, 0)
258 const pct = (r: number, a: number): string => (a === 0 ? '-' : `${Math.round((100 * r) / a)}%`)
259 // Only the topics actually answered, most answered first: the set is open
260 // now, so listing every topic that exists would be a wall of dashes.
261 const topics = rows.length === 0
262 ? 'nothing answered yet'
263 : rows.slice(0, 6).map(([t, row]) => `${labelOf(t)} ${pct(row.right, row.asked)} (${row.asked})`).join(' · ')
264 return [
265 `${session.isPaused ? 'Paused. ' : ''}${day.count}/${session.dailyGoal} cards today.`,
266 `Questions answered: ${asked}, right: ${right} (${pct(right, asked)}).`,
267 `By topic: ${topics}.`,
268 `Cards available: ${CARDS.length} shipped${session.isAiOn ? `, ${session.library.length} generated` : ' (generation off)'}.`,
269 session.isPaused ? '`/holdtime resume` turns the cards back on.' : '`/holdtime pause` turns the cards off.',
270 ].join('\n')
271}
272
273async function setPaused($: EngineInterface, isPaused: boolean): Promise<void> {
274 session.isPaused = isPaused
275 await $.store.set('paused', isPaused)
276 if (isPaused) {
277 forgetUnanswered()
278 await update($, cardAtom, () => null)
279 }
280}
281
282async function reset($: EngineInterface): Promise<void> {
283 await $.store.delete('progress')
284 await $.store.delete('day')
285 await $.store.delete('library')
286 session.progress = {}
287 session.shown.clear()
288 // Generated cards are saved data too, so "start fresh" drops them as well.
289 // The next topic signal fills the library again, right away rather than
290 // after the usual wait between requests.
291 session.library = []
292 session.triedAt = {}
293 // Leave no card or count behind: one still on screen would be graded onto
294 // the progress that was just deleted, and this turn's tally would describe
295 // cards that no longer count towards anything.
296 forgetUnanswered()
297 const fresh: TurnTally = { seen: 0, asked: 0, right: 0 }
298 await update($, cardAtom, () => null)
299 await update($, feedbackAtom, () => null)
300 await update($, tallyAtom, () => fresh)
301}
302
303async function startTurn($: EngineInterface): Promise<void> {
304 session.isHiddenThisTurn = false
305 session.ask = null
306 session.weights = decay(session.weights)
307 const fresh: TurnTally = { seen: 0, asked: 0, right: 0 }
308 await update($, tallyAtom, () => fresh)
309 await update($, needsYouAtom, () => false)
310 await showNext($)
311}
312
313async function setNeedsYou($: EngineInterface, needsYou: boolean): Promise<void> {
314 if ((await read($, needsYouAtom)) !== needsYou) await update($, needsYouAtom, () => needsYou)
315}
316
317async function waitForYou($: EngineInterface, tool: string, key: string): Promise<void> {
318 session.ask = { tool, key, at: await $.clock.now() }
319 await setNeedsYou($, true)
320}
321
322async function noteCall($: EngineInterface, call: { tool: string; key: string; startedAt: number }, isFinished: boolean): Promise<void> {
323 if (session.ask && isAnswered(session.ask, call, isFinished)) {
324 session.ask = null
325 await setNeedsYou($, false)
326 }
327}
328
329async function now($: EngineInterface): Promise<number> {
330 return $.clock.now()
331}
332
333/** Clears the band and says how the turn went, or nothing if no card was seen. */
334async function endTurn($: EngineInterface): Promise<string | null> {
335 const tally = await read($, tallyAtom)
336 forgetUnanswered()
337 session.ask = null
338 await update($, cardAtom, () => null)
339 await update($, feedbackAtom, () => null)
340 await setNeedsYou($, false)
341 if (tally.seen === 0) return null
342 const cards = `${tally.seen} card${tally.seen === 1 ? '' : 's'}`
343 const right = tally.asked > 0 ? `, ${tally.right}/${tally.asked} right` : ''
344 const day = await readDay($)
345 return `Holdtime: ${cards} this turn${right} · ${day.count}/${session.dailyGoal} today`
346}
347
348async function load($: EngineInterface): Promise<void> {
349 await $.command.register({
350 name: 'holdtime',
351 description: 'Holdtime: your learning stats, or pause, resume or reset the cards',
352 argumentHint: '[stats | pause | resume | reset]',
353 immediate: true,
354 })
355 session.progress = ((await $.store.get('progress')) as Progress | undefined) ?? {}
356 session.isPaused = (await $.store.get('paused')) === true
357 session.library = readLibrary(await $.store.get('library'))
358 const found: Topic[] = []
359 for (const [file, topic] of PROJECT_FILES) {
360 if (await $.fs.exists(file)) found.push(topic)
361 }
362 session.weights = seed(freshWeights(), found)
363 // Fill the library for what this project is written in, before the first
364 // turn needs a card. Only the timer is awaited, not the generation itself,
365 // so the session starts right away.
366 await queueRefill($)
367}
368
369export const register: Register = (on, options) => {
370 session.dailyGoal = Number(options['daily_goal'] ?? 20)
371 session.maxPerTurn = Number(options['max_per_turn'] ?? 5)
372 session.isAiOn = options['ai_cards'] !== false
373 session.weights = freshWeights()
374 session.shown = new Set<string>()
375 session.unanswered = null
376 session.ask = null
377 session.isBusy = false
378 session.library = []
379 session.isGenerating = false
380 session.refill = null
381 session.triedAt = {}
382
383 on('session.start', async ($, e, next) => {
384 await load($)
385 return next(e)
386 })
387
388 on('command.run', { command: 'holdtime' }, async ($, e) => {
389 const arg = e.args.trim().toLowerCase()
390 if (arg === 'pause') {
391 await setPaused($, true)
392 return { text: 'Paused. `/holdtime resume` brings the cards back.' }
393 }
394 if (arg === 'resume') {
395 await setPaused($, false)
396 return { text: 'On. Cards appear while Claude works.' }
397 }
398 if (arg === 'reset') {
399 await reset($)
400 return { text: 'Progress deleted: every card is new again, and today starts at 0.' }
401 }
402 return { text: await statsText($) }
403 })
404
405 on('turn.start', async ($, e, next) => {
406 const started = await next(e)
407 await startTurn($)
408 return started
409 })
410
411 // Every tool call says something about the session's topic, and tells
412 // whether Claude has moved on from a question it put to the person.
413 on('tool.call', async ($, e, next) => {
414 const input = e as unknown as Readonly<Record<string, unknown>>
415 const topic = topicOf(e.tool, input)
416 if (topic) {
417 session.weights = bump(session.weights, topic)
418 await queueRefill($)
419 }
420 const call = { tool: e.tool, key: keyOf(input), startedAt: await now($) }
421 await noteCall($, call, false)
422 const ran = await next(e)
423 await noteCall($, call, true)
424 return ran
425 })
426
427 on('classic.PermissionRequest', async ($, e, next) => {
428 await waitForYou($, e.tool_name, keyOf((e.tool_input ?? {}) as Readonly<Record<string, unknown>>))
429 return next(e)
430 })
431
432 on('classic.Notification', async ($, e, next) => {
433 if (NEEDS_YOU.has(e.notification_type)) await waitForYou($, '', '')
434 return next(e)
435 })
436
437 on('turn.complete', async ($, e, next) => {
438 const done = await next(e)
439 if (e.agentId) return done
440 const summary = await endTurn($)
441 return summary ? { ...done, text: summary } : done
442 })
443
444 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
445 if (e.props.hasSurvey || !e.props.isWorking || e.props.view.agentId) return next(e)
446 const card = await read($, cardAtom)
447 const feedback = await read($, feedbackAtom)
448 const needsYou = await read($, needsYouAtom)
449 if (needsYou || !card) return next(e)
450
451 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
452 const isAsking = card.kind === 'yesno' && !feedback
453 const title = `Holdtime · ${labelOf(card.topic)}${isAsking ? ' · yes or no?' : ''}`
454 const body = feedback ? `${feedback.isRight ? '**Right.**' : '**Not quite.**'} ${feedback.text}` : card.text
455
456 // The buttons share the first row with the title: a band shorter than the
457 // card scrolls, and a bare digit only presses a button in view.
458 return (
459 <Box flexDirection="column">
460 <Box flexDirection="row" columnGap={3}>
461 <Text dimColor>{title}</Text>
462 {isAsking && <Button key="yes" label="Yes" hotkey="1" plain onPress={() => answer($, card, true)} />}
463 {isAsking && <Button key="no" label="No" hotkey="2" plain onPress={() => answer($, card, false)} />}
464 {!isAsking && <Button key="next" label={feedback ? 'Next' : 'Got it'} hotkey="1" plain onPress={() => moveOn($, card)} />}
465 <Button key="hide" label="Hide" hotkey="9" plain dimColor onPress={() => hide($)} />
466 </Box>
467 <Markdown text={body} />
468 </Box>
469 )
470 })
471}
472content/index.ts 8 lines1import type { Card } from '../types'
2import { GIT } from './git'
3import { JAVASCRIPT } from './javascript'
4import { PYTHON } from './python'
5
6/** Every card Holdtime ships. */
7export const CARDS: readonly Card[] = [...JAVASCRIPT, ...PYTHON, ...GIT]
8content/generate.ts 124 lines1/**
2 * Turning a model's reply into cards. The prompt asks for strict JSON, and
3 * everything that comes back is checked against `isValidCard` before it is
4 * kept, so a malformed or over-long card is dropped rather than shown. No
5 * Claude Code access here either: `register.tsx` makes the call and passes
6 * the reply in, so this is tested without a model.
7 */
8import type { Card, Topic } from '../types'
9import { hashOf, isValidCard, labelOf, MAX_EXPLAIN, MAX_TEXT, MIN_EXPLAIN } from '../hooks/planner'
10
11/** Cards asked for in one request. Batched, so one round trip fills a while. */
12export const BATCH = 8
13
14/** Tokens one batch needs, with room to spare; the default of 1024 is short. */
15export const BATCH_TOKENS = 2500
16
17export const TIMEOUT_MS = 20_000
18
19/** The model asked for cards: the cheapest one, called often and in the background. */
20export const MODEL = 'haiku'
21
22/** The job, written once and reused for every topic. */
23export const SYSTEM = [
24 'You write very short learning cards for an experienced programmer who is',
25 'waiting a few seconds for an AI coding agent to finish. Each card teaches',
26 'one concrete, correct, non-obvious thing about the topic.',
27 '',
28 'Reply with a JSON array and nothing else. No prose, no code fence.',
29 'Each element is an object with these fields:',
30 ' "kind": "fact" or "yesno"',
31 ` "text": the fact, or the question. Under ${MAX_TEXT} characters.`,
32 ' A "yesno" text must end with "?".',
33 ' "answer": true or false. Only on a "yesno", never on a "fact".',
34 ` "explain": why, in one or two sentences, ${MIN_EXPLAIN}-${MAX_EXPLAIN} characters.`,
35 ' Only on a "yesno", never on a "fact".',
36 '',
37 'Rules:',
38 '- Half the cards "fact", half "yesno".',
39 '- Mix the answers: some true, some false. Never make them all one way.',
40 '- Wrap code, commands and identifiers in backticks.',
41 '- Prefer things people get wrong over things people look up.',
42 '- No opinions, no "it depends", nothing that changes between versions',
43 ' unless the card names the version.',
44].join('\n')
45
46/** The request for one topic, naming cards already held so they are not repeated. */
47export function buildPrompt(topic: Topic, avoid: readonly string[]): string {
48 const lines = [`Topic: ${labelOf(topic)}.`, `Write ${BATCH} cards.`]
49 if (avoid.length > 0) {
50 lines.push('', 'Do not repeat any of these, which the reader already has:')
51 for (const text of avoid.slice(0, 40)) lines.push(`- ${text}`)
52 }
53 return lines.join('\n')
54}
55
56/** How many openings to try before giving up on a reply. */
57const TRIES = 8
58
59/**
60 * Pulls the JSON array out of a reply. The model is told to send nothing but
61 * the array, but a reply is worth a few attempts before a paid request is
62 * thrown away: every fenced block is tried, then the whole reply, and within
63 * each, every `[` in turn, since prose can hold brackets of its own.
64 */
65function parseArray(reply: string): unknown[] {
66 const candidates: string[] = []
67 for (const match of reply.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)) {
68 const body = match[1]
69 if (body !== undefined) candidates.push(body)
70 }
71 candidates.push(reply)
72
73 let tries = 0
74 for (const candidate of candidates) {
75 const end = candidate.lastIndexOf(']')
76 if (end === -1) continue
77 for (let start = candidate.indexOf('['); start !== -1 && start < end; start = candidate.indexOf('[', start + 1)) {
78 if (tries >= TRIES) return []
79 tries += 1
80 try {
81 const parsed: unknown = JSON.parse(candidate.slice(start, end + 1))
82 if (Array.isArray(parsed)) return parsed
83 } catch {
84 // This opening was not the start of the array; try the next one.
85 }
86 }
87 }
88 return []
89}
90
91/**
92 * The cards in a reply: parsed, given ids and the topic they were asked for,
93 * and filtered down to the ones fit to show. A reply that is not JSON, or
94 * whose cards are all malformed, yields an empty list and nothing is kept.
95 */
96export function parseCards(topic: Topic, reply: string): Card[] {
97 const out: Card[] = []
98 const seen = new Set<string>()
99 for (const raw of parseArray(reply)) {
100 if (typeof raw !== 'object' || raw === null) continue
101 const fields = raw as Record<string, unknown>
102 const text = typeof fields['text'] === 'string' ? fields['text'].trim() : ''
103 if (text.length === 0) continue
104 const kind = fields['kind']
105 // The model is told to leave these off a fact; drop them if it did not.
106 const isAsking = kind === 'yesno'
107 const card: Card = {
108 id: `ai-${topic}-${hashOf(text)}`,
109 topic,
110 kind: isAsking ? 'yesno' : 'fact',
111 text,
112 source: 'ai',
113 ...(isAsking ? { answer: fields['answer'] === true } : {}),
114 ...(isAsking && typeof fields['explain'] === 'string' ? { explain: fields['explain'].trim() } : {}),
115 }
116 if (kind !== 'fact' && kind !== 'yesno') continue
117 if (!isValidCard(card)) continue
118 if (seen.has(card.id)) continue
119 seen.add(card.id)
120 out.push(card)
121 }
122 return out
123}
124hooks/planner.ts 375 lines1/**
2 * Holdtime's decisions, with no access to Claude Code: which topic a tool call
3 * points at, which card comes next, how an answer moves a card between
4 * spaced-repetition boxes, and whether a card is fit to show. Everything here
5 * is a plain function of its inputs, so it is tested directly.
6 */
7import type { Card, Topic } from '../types'
8
9/** A card's place in the Leitner system, kept per card id in the store. */
10export type CardProgress = {
11 /** 1 to 5; a wrong answer sends a card back to box 1. */
12 box: number
13 /** The day (YYYY-MM-DD) the card is next due for review. */
14 due: string
15 seen: number
16 right: number
17}
18
19export type Progress = Readonly<Record<string, CardProgress>>
20
21/**
22 * How much each topic is worth when the next card's topic is drawn. A topic
23 * with no entry is worth `BASE_WEIGHT`, so the map holds only the topics this
24 * session has actually seen rather than every topic that exists.
25 */
26export type Weights = Readonly<Record<Topic, number>>
27
28/** Days until a card in each box comes back: box 1 tomorrow, box 5 in 16 days. */
29export const INTERVAL_DAYS = [1, 2, 4, 8, 16] as const
30
31/** Every topic stays possible, so a session about one language still mixes. */
32export const BASE_WEIGHT = 0.5
33
34/** How much of a topic's weight carries into the next turn. */
35export const DECAY = 0.8
36
37/** The topics the shipped packs cover. */
38export const PACK_TOPICS: readonly Topic[] = ['javascript', 'python', 'git']
39
40export function freshWeights(): Weights {
41 return {}
42}
43
44/** A topic's weight, or the base weight when the session has not seen it. */
45export function weightOf(weights: Weights, topic: Topic): number {
46 return weights[topic] ?? BASE_WEIGHT
47}
48
49/** Fades older signals at the start of a turn, never below the base weight. */
50export function decay(weights: Weights): Weights {
51 const out: Record<Topic, number> = {}
52 for (const [topic, weight] of Object.entries(weights)) {
53 out[topic] = Math.max(BASE_WEIGHT, weight * DECAY)
54 }
55 return out
56}
57
58/** Adds one to a topic's weight. */
59export function bump(weights: Weights, topic: Topic): Weights {
60 return { ...weights, [topic]: weightOf(weights, topic) + 1 }
61}
62
63/** The topic the session leans towards most, or `null` with no signal yet. */
64export function leadingTopic(weights: Weights): Topic | null {
65 let best: Topic | null = null
66 let most = BASE_WEIGHT
67 for (const [topic, weight] of Object.entries(weights)) {
68 if (weight > most) {
69 best = topic
70 most = weight
71 }
72 }
73 return best
74}
75
76const EXTENSIONS: ReadonlyArray<[RegExp, Topic]> = [
77 // The shipped pack covers JavaScript and TypeScript together, so both map
78 // to one topic rather than splitting the pack's cards away from .ts files.
79 [/\.(m|c)?(j|t)sx?$/i, 'javascript'],
80 [/(^|\/)(package\.json|tsconfig[^/]*\.json)$/i, 'javascript'],
81 [/\.pyi?$/i, 'python'],
82 [/(^|\/)(pyproject\.toml|requirements[^/]*\.txt|setup\.py)$/i, 'python'],
83 [/(^|\/)\.git(ignore|attributes|modules)$/i, 'git'],
84 [/\.rs$/i, 'rust'],
85 [/(^|\/)Cargo\.toml$/i, 'rust'],
86 [/\.go$/i, 'go'],
87 [/(^|\/)go\.(mod|sum)$/i, 'go'],
88 [/\.rb$/i, 'ruby'],
89 [/(^|\/)(Gemfile|Rakefile)$/i, 'ruby'],
90 [/\.java$/i, 'java'],
91 [/(^|\/)(pom\.xml|build\.gradle(\.kts)?)$/i, 'java'],
92 [/\.kts?$/i, 'kotlin'],
93 [/\.swift$/i, 'swift'],
94 [/\.php$/i, 'php'],
95 [/\.cs$/i, 'csharp'],
96 [/\.sql$/i, 'sql'],
97 [/\.tf(vars)?$/i, 'terraform'],
98 [/(^|\/)(Dockerfile|docker-compose\.ya?ml|compose\.ya?ml)$/i, 'docker'],
99 [/\.(sh|bash|zsh)$/i, 'shell'],
100 [/\.s?css$/i, 'css'],
101]
102
103const PROGRAMS: Readonly<Record<string, Topic>> = {
104 git: 'git',
105 gh: 'git',
106 node: 'javascript',
107 npm: 'javascript',
108 npx: 'javascript',
109 pnpm: 'javascript',
110 yarn: 'javascript',
111 bun: 'javascript',
112 deno: 'javascript',
113 tsc: 'javascript',
114 python: 'python',
115 python3: 'python',
116 pip: 'python',
117 pip3: 'python',
118 pytest: 'python',
119 uv: 'python',
120 poetry: 'python',
121 ruff: 'python',
122 mypy: 'python',
123 cargo: 'rust',
124 rustc: 'rust',
125 rustup: 'rust',
126 go: 'go',
127 gofmt: 'go',
128 ruby: 'ruby',
129 rails: 'ruby',
130 bundle: 'ruby',
131 gem: 'ruby',
132 rake: 'ruby',
133 java: 'java',
134 javac: 'java',
135 mvn: 'java',
136 gradle: 'java',
137 kotlinc: 'kotlin',
138 swift: 'swift',
139 php: 'php',
140 composer: 'php',
141 dotnet: 'csharp',
142 psql: 'sql',
143 mysql: 'sql',
144 sqlite3: 'sql',
145 docker: 'docker',
146 'docker-compose': 'docker',
147 podman: 'docker',
148 kubectl: 'kubernetes',
149 helm: 'kubernetes',
150 k9s: 'kubernetes',
151 minikube: 'kubernetes',
152 terraform: 'terraform',
153 tofu: 'terraform',
154}
155
156/**
157 * The topic one tool call points at: a file's extension for the file tools,
158 * the program a shell command starts with for Bash. `null` when neither says.
159 */
160export function topicOf(tool: string, input: Readonly<Record<string, unknown>>): Topic | null {
161 const path = input['file_path'] ?? input['notebook_path'] ?? input['path']
162 if (typeof path === 'string') {
163 const normal = path.replace(/\\/g, '/')
164 for (const [pattern, topic] of EXTENSIONS) {
165 if (pattern.test(normal)) return topic
166 }
167 }
168 const command = input['command']
169 if ((tool === 'Bash' || tool === 'PowerShell') && typeof command === 'string') {
170 for (const step of command.split(/&&|\|\||;|\|/)) {
171 const words = step.trim().split(/\s+/)
172 // Skip leading `FOO=bar` assignments.
173 const program = words.find(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
174 const topic = program ? PROGRAMS[program.replace(/^.*\//, '').toLowerCase()] : undefined
175 if (topic) return topic
176 }
177 }
178 return null
179}
180
181/**
182 * Project files that say what a repository is written in, checked once at
183 * session start so the very first card already fits the project.
184 */
185export const PROJECT_FILES: ReadonlyArray<[string, Topic]> = [
186 ['package.json', 'javascript'],
187 ['tsconfig.json', 'javascript'],
188 ['pyproject.toml', 'python'],
189 ['requirements.txt', 'python'],
190 ['setup.py', 'python'],
191 ['Cargo.toml', 'rust'],
192 ['go.mod', 'go'],
193 ['Gemfile', 'ruby'],
194 ['pom.xml', 'java'],
195 ['composer.json', 'php'],
196 ['Dockerfile', 'docker'],
197]
198
199/** Raises the weight of each topic a project file pointed at, once per topic. */
200export function seed(weights: Weights, found: readonly Topic[]): Weights {
201 return [...new Set(found)].reduce(bump, weights)
202}
203
204const LABELS: Readonly<Record<string, string>> = {
205 javascript: 'JavaScript',
206 typescript: 'TypeScript',
207 python: 'Python',
208 git: 'Git',
209 rust: 'Rust',
210 go: 'Go',
211 ruby: 'Ruby',
212 java: 'Java',
213 kotlin: 'Kotlin',
214 swift: 'Swift',
215 php: 'PHP',
216 csharp: 'C#',
217 sql: 'SQL',
218 docker: 'Docker',
219 kubernetes: 'Kubernetes',
220 terraform: 'Terraform',
221 shell: 'Shell',
222 css: 'CSS',
223}
224
225/** The name a topic is shown under in the band and in `/holdtime`. */
226export function labelOf(topic: Topic): string {
227 return LABELS[topic] ?? topic.charAt(0).toUpperCase() + topic.slice(1)
228}
229
230/**
231 * A tool call Claude asked the person to approve, or a question it put to
232 * them. `tool` and `key` identify the call; both are empty for a question.
233 */
234export type Ask = { tool: string; key: string; at: number }
235
236/** The input field that tells one call of a tool from another. */
237export function keyOf(input: Readonly<Record<string, unknown>>): string {
238 for (const field of ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'query']) {
239 const value = input[field]
240 if (typeof value === 'string') return value
241 }
242 return ''
243}
244
245/**
246 * Whether Claude has moved on from an ask: the call that asked has finished,
247 * or a new tool call started after it, which Claude only does once answered.
248 */
249export function isAnswered(ask: Ask, call: { tool: string; key: string; startedAt: number }, isFinished: boolean): boolean {
250 if (!isFinished) return call.startedAt > ask.at
251 return ask.tool !== '' && call.tool === ask.tool && call.key === ask.key
252}
253
254/** The day of a timestamp, as YYYY-MM-DD in UTC. */
255export function dayOf(ms: number): string {
256 return new Date(ms).toISOString().slice(0, 10)
257}
258
259export function addDays(day: string, days: number): string {
260 return dayOf(Date.parse(`${day}T00:00:00Z`) + days * 86_400_000)
261}
262
263/**
264 * Records one answer. `isRight` is `null` for a fact, which was only read:
265 * it moves up a box like a right answer, so facts come back less and less.
266 */
267export function grade(progress: Progress, card: Card, isRight: boolean | null, today: string): Progress {
268 const before = progress[card.id] ?? { box: 0, due: today, seen: 0, right: 0 }
269 const box = isRight === false ? 1 : Math.min(INTERVAL_DAYS.length, before.box + 1)
270 const wait = INTERVAL_DAYS[box - 1] ?? 1
271 return {
272 ...progress,
273 [card.id]: {
274 box,
275 due: addDays(today, wait),
276 seen: before.seen + 1,
277 right: before.right + (isRight === true ? 1 : 0),
278 },
279 }
280}
281
282/** The longest a card's question or fact may be, so the band stays one line. */
283export const MAX_TEXT = 130
284
285/** The bounds an explanation has to fall inside. */
286export const MIN_EXPLAIN = 11
287export const MAX_EXPLAIN = 199
288
289/**
290 * Whether a card is fit to show. The shipped packs are checked against this in
291 * the tests, and every generated card is filtered through it before it is
292 * kept, so a model that writes something too long, one-sided or malformed has
293 * that card dropped rather than shown.
294 */
295export function isValidCard(value: unknown): value is Card {
296 if (typeof value !== 'object' || value === null) return false
297 const card = value as Partial<Card>
298 if (typeof card.id !== 'string' || card.id.length === 0) return false
299 if (typeof card.topic !== 'string' || card.topic.length === 0) return false
300 if (typeof card.text !== 'string') return false
301 if (card.text.length === 0 || card.text.length >= MAX_TEXT) return false
302 if (card.kind === 'fact') return card.answer === undefined
303 if (card.kind !== 'yesno') return false
304 if (typeof card.answer !== 'boolean') return false
305 if (!card.text.endsWith('?')) return false
306 const explain = card.explain
307 return typeof explain === 'string' && explain.length >= MIN_EXPLAIN && explain.length <= MAX_EXPLAIN
308}
309
310/** A short stable hash of some text, used to give a generated card its id. */
311export function hashOf(text: string): string {
312 let hash = 0x811c9dc5
313 for (let i = 0; i < text.length; i += 1) {
314 hash ^= text.charCodeAt(i)
315 hash = Math.imul(hash, 0x01000193) >>> 0
316 }
317 return hash.toString(36)
318}
319
320/** Picks from `items` with chances in proportion to `weightFor`. */
321function weighted<T>(items: readonly T[], weightFor: (item: T) => number, rand: () => number): T | undefined {
322 const total = items.reduce((sum, item) => sum + Math.max(0, weightFor(item)), 0)
323 if (total <= 0) return items.at(Math.floor(rand() * items.length))
324 let left = rand() * total
325 for (const item of items) {
326 left -= Math.max(0, weightFor(item))
327 if (left < 0) return item
328 }
329 return items.at(-1)
330}
331
332/**
333 * The next card. A topic is drawn by weight from the topics the remaining
334 * cards cover; within it, a card due for review comes first one time in three,
335 * else a card never seen, else any card not shown this session. Returns `null`
336 * when nothing is left to show.
337 */
338export function pickCard(
339 cards: readonly Card[],
340 weights: Weights,
341 progress: Progress,
342 today: string,
343 shown: ReadonlySet<string>,
344 rand: () => number = Math.random,
345): Card | null {
346 const fresh = cards.filter(card => !shown.has(card.id))
347 if (fresh.length === 0) return null
348
349 const topics = [...new Set(fresh.map(card => card.topic))]
350 const topic = weighted(topics, t => weightOf(weights, t), rand) ?? topics[0]
351 const pool = fresh.filter(card => card.topic === topic)
352
353 const due = pool.filter(card => {
354 const p = progress[card.id]
355 return p !== undefined && p.due <= today
356 })
357 const unseen = pool.filter(card => progress[card.id] === undefined)
358
359 const roll = rand()
360 const from = due.length > 0 && (roll < 1 / 3 || unseen.length === 0) ? due : unseen.length > 0 ? unseen : pool
361 return from.at(Math.floor(rand() * from.length)) ?? null
362}
363
364/** Right answers out of answered cards, per topic, from the stored progress. */
365export function accuracyByTopic(cards: readonly Card[], progress: Progress): Record<Topic, { asked: number; right: number }> {
366 const out: Record<Topic, { asked: number; right: number }> = {}
367 for (const card of cards) {
368 const p = progress[card.id]
369 if (!p || card.kind !== 'yesno') continue
370 const row = out[card.topic] ?? { asked: 0, right: 0 }
371 out[card.topic] = { asked: row.asked + p.seen, right: row.right + p.right }
372 }
373 return out
374}
375hooks/pool.ts 71 lines1/**
2 * The library of generated cards: what is kept, when more are needed, and
3 * which topic to ask about next. Pure functions, so the refill policy is
4 * tested without a model or a store.
5 */
6import type { Card, Topic } from '../types'
7import { isValidCard, leadingTopic, PACK_TOPICS, type Weights } from './planner'
8
9/** Generated cards kept at once. Old ones fall off the end as new arrive. */
10export const LIBRARY_MAX = 240
11
12/**
13 * Cards of the current topic that have to be ready and unshown before a
14 * refill is worth a request. Below this, one batch is fetched in the
15 * background; the shipped packs cover the gap meanwhile.
16 */
17export const LOW_WATER = 6
18
19/** Reads a stored library back, dropping anything that is not a card now. */
20export function readLibrary(stored: unknown): Card[] {
21 if (!Array.isArray(stored)) return []
22 return stored.filter(isValidCard)
23}
24
25/**
26 * Adds new cards to the library, newest first, skipping any whose id or text
27 * is already held, and trimming the oldest away past `LIBRARY_MAX`.
28 */
29export function merge(library: readonly Card[], incoming: readonly Card[], cap = LIBRARY_MAX): Card[] {
30 const ids = new Set(library.map(card => card.id))
31 const texts = new Set(library.map(card => card.text.toLowerCase()))
32 const fresh: Card[] = []
33 for (const card of incoming) {
34 if (ids.has(card.id) || texts.has(card.text.toLowerCase())) continue
35 ids.add(card.id)
36 texts.add(card.text.toLowerCase())
37 fresh.push(card)
38 }
39 return [...fresh, ...library].slice(0, cap)
40}
41
42/** Cards of one topic that this session has not shown yet. */
43export function readyFor(library: readonly Card[], topic: Topic, shown: ReadonlySet<string>): number {
44 return library.filter(card => card.topic === topic && !shown.has(card.id)).length
45}
46
47/**
48 * Whether to spend a request on `topic` now: only when the library is short
49 * of unshown cards for it. A topic the packs already cover is still worth
50 * generating for, because the pack runs out within a session or two.
51 */
52export function needsRefill(library: readonly Card[], topic: Topic, shown: ReadonlySet<string>): boolean {
53 return readyFor(library, topic, shown) < LOW_WATER
54}
55
56/**
57 * The topic to generate for: whatever the session leans towards, or a pack
58 * topic when nothing has pointed anywhere yet, so an idle session still fills
59 * its library instead of waiting for a signal that may never come.
60 */
61export function topicToFill(weights: Weights, rand: () => number = Math.random): Topic {
62 const leading = leadingTopic(weights)
63 if (leading !== null) return leading
64 return PACK_TOPICS[Math.floor(rand() * PACK_TOPICS.length)] ?? 'git'
65}
66
67/** The texts of the cards already held for a topic, to send as "do not repeat". */
68export function avoidFor(library: readonly Card[], packs: readonly Card[], topic: Topic): string[] {
69 return [...library, ...packs].filter(card => card.topic === topic).map(card => card.text)
70}
71content/git.ts 114 lines1import type { Card } from '../types'
2
3/** Git. Written for Holdtime; MIT, like the rest. */
4export const GIT: readonly Card[] = [
5 { id: 'git-switch', topic: 'git', kind: 'fact', text: '`git switch -c name` creates a branch and moves to it; it is the newer form of `git checkout -b`.' },
6 { id: 'git-restore', topic: 'git', kind: 'fact', text: '`git restore file` throws away unstaged changes; `git restore --staged file` unstages without losing them.' },
7 { id: 'git-amend', topic: 'git', kind: 'fact', text: '`git commit --amend` replaces the last commit with a new one, which gets a new hash.' },
8 { id: 'git-reflog', topic: 'git', kind: 'fact', text: '`git reflog` lists where HEAD has been, so you can find "lost" commits after a bad reset or rebase.' },
9 { id: 'git-stash-message', topic: 'git', kind: 'fact', text: '`git stash push -m "wip login"` labels a stash; `git stash pop` reapplies the latest and drops it.' },
10 { id: 'git-log-graph', topic: 'git', kind: 'fact', text: '`git log --oneline --graph --all` draws every branch\'s history as a compact graph.' },
11 { id: 'git-bisect', topic: 'git', kind: 'fact', text: '`git bisect` binary-searches your history to find the commit that introduced a bug.' },
12 { id: 'git-blame-w', topic: 'git', kind: 'fact', text: '`git blame -w` ignores whitespace-only changes when deciding who last changed a line.' },
13 { id: 'git-force-lease', topic: 'git', kind: 'fact', text: '`git push --force-with-lease` refuses to overwrite the remote if someone pushed since you fetched.' },
14 { id: 'git-cherry-pick', topic: 'git', kind: 'fact', text: '`git cherry-pick <hash>` applies the changes of one commit on top of your current branch.' },
15 { id: 'git-ignore-tracked', topic: 'git', kind: 'fact', text: '`.gitignore` does not affect files Git already tracks. Stop tracking one with `git rm --cached file`.' },
16 { id: 'git-diff-staged', topic: 'git', kind: 'fact', text: '`git diff --staged` shows exactly what will go into your next commit.' },
17 { id: 'git-worktree', topic: 'git', kind: 'fact', text: '`git worktree add ../hotfix main` checks out a second branch in another folder, sharing one repository.' },
18 { id: 'git-fetch-pull', topic: 'git', kind: 'fact', text: '`git pull` is `git fetch` followed by a merge (or a rebase, with `--rebase`).' },
19 { id: 'git-head-parents', topic: 'git', kind: 'fact', text: '`HEAD~2` goes two commits back along first parents; `HEAD^2` is the second parent of a merge commit.' },
20 { id: 'git-add-p', topic: 'git', kind: 'fact', text: '`git add -p` stages a file piece by piece, so one commit can take only part of your changes.' },
21 { id: 'git-fixup', topic: 'git', kind: 'fact', text: '`git commit --fixup <hash>`, then `git rebase -i --autosquash`, folds a fix into an earlier commit.' },
22 { id: 'git-range', topic: 'git', kind: 'fact', text: '`git log main..feature` lists the commits on feature that are not on main.' },
23 {
24 id: 'git-rebase-hashes', topic: 'git', kind: 'yesno', answer: true,
25 text: 'Does `git rebase` give the rebased commits new hashes?',
26 explain: 'Yes. Each commit gets a new parent, and the parent is part of the hash, so every hash changes.',
27 },
28 {
29 id: 'git-fetch-files', topic: 'git', kind: 'yesno', answer: false,
30 text: 'Does `git fetch` change the files in your working folder?',
31 explain: 'No. It only updates remote-tracking branches like origin/main. Merging or rebasing changes files.',
32 },
33 {
34 id: 'git-revert-delete', topic: 'git', kind: 'yesno', answer: false,
35 text: 'Does `git revert` remove the original commit from history?',
36 explain: 'No. It adds a new commit that undoes it, which is why it is safe on shared branches.',
37 },
38 {
39 id: 'git-reset-soft', topic: 'git', kind: 'yesno', answer: true,
40 text: 'After `git reset --soft HEAD~1`, are the last commit\'s changes still staged?',
41 explain: 'Yes. `--soft` moves the branch back and leaves the changes staged, ready to commit again.',
42 },
43 {
44 id: 'git-reset-hard', topic: 'git', kind: 'yesno', answer: false,
45 text: 'Does `git reset --hard` keep your uncommitted changes?',
46 explain: 'No, it discards them. Stash first (`git stash`) if you might need them.',
47 },
48 {
49 id: 'git-hash-parent', topic: 'git', kind: 'yesno', answer: true,
50 text: 'Does a commit\'s hash depend on its parent commit?',
51 explain: 'Yes. The parent\'s hash is part of what gets hashed, which chains history together.',
52 },
53 {
54 id: 'git-empty-dirs', topic: 'git', kind: 'yesno', answer: false,
55 text: 'Does Git track empty directories?',
56 explain: 'No, Git tracks files only. A common trick is to add a placeholder file such as `.gitkeep`.',
57 },
58 {
59 id: 'git-ff-only', topic: 'git', kind: 'yesno', answer: false,
60 text: 'Can `git merge --ff-only` create a merge commit?',
61 explain: 'No. It fast-forwards or stops with an error; it never makes a merge commit.',
62 },
63 {
64 id: 'git-stash-untracked', topic: 'git', kind: 'yesno', answer: false,
65 text: 'Does plain `git stash` save untracked files?',
66 explain: 'No. Add `-u` (`--include-untracked`) to stash new files too.',
67 },
68 {
69 id: 'git-shallow-clone', topic: 'git', kind: 'yesno', answer: false,
70 text: 'Does `git clone --depth 1` download the full history?',
71 explain: 'No, only the latest commit. It is a shallow clone; `git fetch --unshallow` gets the rest.',
72 },
73 {
74 id: 'git-origin-keyword', topic: 'git', kind: 'yesno', answer: false,
75 text: 'Is `origin` a special keyword built into Git?',
76 explain: 'No. It is just the default name `git clone` gives the remote; you can rename it.',
77 },
78 {
79 id: 'git-commit-a', topic: 'git', kind: 'yesno', answer: false,
80 text: 'Does `git commit -a` include brand-new, untracked files?',
81 explain: 'No. `-a` stages changes to files Git already tracks; new files still need `git add`.',
82 },
83 {
84 id: 'git-cherry-pick-hash', topic: 'git', kind: 'yesno', answer: false,
85 text: 'Does a cherry-picked commit keep its original hash?',
86 explain: 'No. It is a new commit with a new parent, so it gets a new hash.',
87 },
88 {
89 id: 'git-push-tags', topic: 'git', kind: 'yesno', answer: false,
90 text: 'Does a plain `git push` send your tags too?',
91 explain: 'No. Push a tag by name (`git push origin v1.0`) or all of them with `--tags`.',
92 },
93 {
94 id: 'git-branch-d', topic: 'git', kind: 'yesno', answer: false,
95 text: 'Does `git branch -d` delete a branch that has unmerged commits?',
96 explain: 'No, it refuses. `-D` forces it, and the commits stay findable in the reflog for a while.',
97 },
98 {
99 id: 'git-checkout-file', topic: 'git', kind: 'yesno', answer: true,
100 text: 'Can `git checkout -- file` throw away your unstaged changes to that file?',
101 explain: 'Yes, without asking. The newer `git restore file` does the same.',
102 },
103 {
104 id: 'git-revert-merge', topic: 'git', kind: 'yesno', answer: true,
105 text: 'Does reverting a merge commit need `-m 1` to say which parent to keep?',
106 explain: 'Yes. A merge has two parents, so `git revert -m 1 <hash>` says to keep the first parent\'s side.',
107 },
108 {
109 id: 'git-tag-annotated', topic: 'git', kind: 'yesno', answer: true,
110 text: 'Does `git tag -a v1.0 -m "msg"` store the tagger and date?',
111 explain: 'Yes, that is an annotated tag: a full object with tagger, date and message. A plain tag is just a name.',
112 },
113]
114content/javascript.ts 114 lines1import type { Card } from '../types'
2
3/** JavaScript and TypeScript. Written for Holdtime; MIT, like the rest. */
4export const JAVASCRIPT: readonly Card[] = [
5 { id: 'js-at', topic: 'javascript', kind: 'fact', text: '`arr.at(-1)` returns the last element; `arr[-1]` is just undefined.' },
6 { id: 'js-structured-clone', topic: 'javascript', kind: 'fact', text: '`structuredClone(value)` deep-copies objects, including Maps, Sets and Dates. Functions cannot be cloned.' },
7 { id: 'js-group-by', topic: 'javascript', kind: 'fact', text: '`Object.groupBy(items, fn)` groups a list into an object of arrays keyed by what `fn` returns (ES2024).' },
8 { id: 'js-nullish', topic: 'javascript', kind: 'fact', text: '`a ?? b` falls back only when `a` is null or undefined; `a || b` also falls back on 0, "" and false.' },
9 { id: 'js-optional-chain', topic: 'javascript', kind: 'fact', text: '`a?.b.c` stops and gives undefined when `a` is null or undefined, instead of throwing.' },
10 { id: 'js-all-settled', topic: 'javascript', kind: 'fact', text: '`Promise.allSettled` waits for every promise and never rejects; `Promise.all` rejects on the first failure.' },
11 { id: 'js-satisfies', topic: 'javascript', kind: 'fact', text: 'TypeScript `satisfies` checks a value against a type but keeps the value\'s own, narrower inferred type.' },
12 { id: 'js-to-sorted', topic: 'javascript', kind: 'fact', text: '`arr.toSorted()` returns a sorted copy; `arr.sort()` sorts the array in place (ES2023).' },
13 { id: 'js-const', topic: 'javascript', kind: 'fact', text: '`const` stops reassignment, not mutation: the properties of a const object can still change.' },
14 { id: 'js-json-undefined', topic: 'javascript', kind: 'fact', text: '`JSON.stringify` drops undefined and function values from objects, and writes them as null inside arrays.' },
15 { id: 'js-typeof-null', topic: 'javascript', kind: 'fact', text: '`typeof null` is "object": a bug from the first version of JavaScript, kept for compatibility.' },
16 { id: 'js-unknown', topic: 'javascript', kind: 'fact', text: 'In TypeScript, an `unknown` value must be narrowed before use; `any` switches type checking off.' },
17 { id: 'js-for-in', topic: 'javascript', kind: 'fact', text: '`for...of` loops over values; `for...in` loops over enumerable keys, inherited ones included.' },
18 { id: 'js-number-isnan', topic: 'javascript', kind: 'fact', text: '`Number.isNaN("abc")` is false, but global `isNaN("abc")` is true: the global one converts to a number first.' },
19 { id: 'js-as-const', topic: 'javascript', kind: 'fact', text: 'TypeScript `as const` makes a literal readonly and keeps its exact values as types, like `"GET"` instead of string.' },
20 { id: 'js-array-from', topic: 'javascript', kind: 'fact', text: '`Array.from({ length: 3 }, (_, i) => i)` builds `[0, 1, 2]`.' },
21 { id: 'js-microtasks', topic: 'javascript', kind: 'fact', text: 'Promise callbacks (microtasks) run before the next `setTimeout` callback, even one with a delay of 0.' },
22 { id: 'js-replace-all', topic: 'javascript', kind: 'fact', text: '`"a-a".replace("-", "+")` changes only the first match; `replaceAll` changes every one.' },
23 {
24 id: 'js-sort-default', topic: 'javascript', kind: 'yesno', answer: false,
25 text: 'Does `[1, 2, 10].sort()` return `[1, 2, 10]`?',
26 explain: 'The default sort compares strings, giving [1, 10, 2]. Pass a comparator: `(a, b) => a - b`.',
27 },
28 {
29 id: 'js-nan-equal', topic: 'javascript', kind: 'yesno', answer: false,
30 text: 'Is `NaN === NaN` true?',
31 explain: 'NaN is not equal to anything, itself included. Use `Number.isNaN(x)` or `Object.is(x, NaN)`.',
32 },
33 {
34 id: 'js-object-is-zero', topic: 'javascript', kind: 'yesno', answer: false,
35 text: 'Does `Object.is(-0, 0)` return true?',
36 explain: '`Object.is` tells -0 and 0 apart, unlike `===`, which treats them as equal.',
37 },
38 {
39 id: 'js-async-returns', topic: 'javascript', kind: 'yesno', answer: true,
40 text: 'Does an `async` function always return a Promise?',
41 explain: 'Yes. A plain return value is wrapped in a resolved Promise, and a throw becomes a rejected one.',
42 },
43 {
44 id: 'js-tdz', topic: 'javascript', kind: 'yesno', answer: false,
45 text: 'Can you read a `let` variable on a line above its declaration?',
46 explain: 'No. It exists but sits in the temporal dead zone until declared, so reading it throws a ReferenceError.',
47 },
48 {
49 id: 'js-float', topic: 'javascript', kind: 'yesno', answer: false,
50 text: 'Is `0.1 + 0.2 === 0.3` true?',
51 explain: 'Binary floating point gives 0.30000000000000004. Compare with a small tolerance instead.',
52 },
53 {
54 id: 'js-foreach-await', topic: 'javascript', kind: 'yesno', answer: false,
55 text: 'Does `await` inside a `forEach` callback make `forEach` wait for it?',
56 explain: '`forEach` ignores the returned promises. Use `for...of` with await, or `Promise.all` with `map`.',
57 },
58 {
59 id: 'js-typeof-array', topic: 'javascript', kind: 'yesno', answer: false,
60 text: 'Is `typeof []` equal to "array"?',
61 explain: 'It is "object". Use `Array.isArray(value)` to check for an array.',
62 },
63 {
64 id: 'js-arrow-this', topic: 'javascript', kind: 'yesno', answer: false,
65 text: 'Does an arrow function get its own `this`?',
66 explain: 'No. It uses the `this` of the code around it, which is why arrows suit callbacks inside methods.',
67 },
68 {
69 id: 'js-json-date', topic: 'javascript', kind: 'yesno', answer: false,
70 text: 'Does `JSON.parse(JSON.stringify(new Date()))` give back a Date?',
71 explain: 'It gives an ISO string. Convert it back with `new Date(text)`.',
72 },
73 {
74 id: 'js-enum-runtime', topic: 'javascript', kind: 'yesno', answer: true,
75 text: 'Does a regular TypeScript `enum` produce JavaScript code at runtime?',
76 explain: 'Yes, an object holding the members. Most types vanish when compiled, but enums do not.',
77 },
78 {
79 id: 'js-set-objects', topic: 'javascript', kind: 'yesno', answer: true,
80 text: 'Can a `Set` hold two different objects that have identical contents?',
81 explain: 'Yes. A Set compares objects by reference, so `{}` and `{}` are two different members.',
82 },
83 {
84 id: 'js-strict-implicit-any', topic: 'javascript', kind: 'yesno', answer: true,
85 text: 'Does `"strict": true` in tsconfig turn on `noImplicitAny`?',
86 explain: 'Yes. `strict` enables a family of checks, including `noImplicitAny` and `strictNullChecks`.',
87 },
88 {
89 id: 'js-array-holes', topic: 'javascript', kind: 'yesno', answer: false,
90 text: 'Does `new Array(3).map((_, i) => i)` give `[0, 1, 2]`?',
91 explain: '`new Array(3)` has empty slots, and `map` skips them. Use `Array.from({ length: 3 }, (_, i) => i)`.',
92 },
93 {
94 id: 'js-race-reject', topic: 'javascript', kind: 'yesno', answer: true,
95 text: 'Does `Promise.race` reject if the first promise to settle rejects?',
96 explain: 'Yes. It settles the same way as whichever promise settles first, resolved or rejected.',
97 },
98 {
99 id: 'js-spread-deep', topic: 'javascript', kind: 'yesno', answer: false,
100 text: 'Does `{ ...obj }` deep-copy nested objects?',
101 explain: 'No, it is a shallow copy: nested objects are shared. Use `structuredClone` for a deep copy.',
102 },
103 {
104 id: 'js-includes-nan', topic: 'javascript', kind: 'yesno', answer: true,
105 text: 'Does `[NaN].includes(NaN)` return true?',
106 explain: 'Yes. `includes` uses SameValueZero, which matches NaN, while `indexOf(NaN)` returns -1.',
107 },
108 {
109 id: 'js-interface-merge', topic: 'javascript', kind: 'yesno', answer: true,
110 text: 'Are two TypeScript `interface` declarations with the same name merged into one?',
111 explain: 'Yes, declaration merging combines their members. Type aliases with `type` cannot be merged.',
112 },
113]
114content/python.ts 114 lines1import type { Card } from '../types'
2
3/** Python. Written for Holdtime; MIT, like the rest. */
4export const PYTHON: readonly Card[] = [
5 { id: 'py-mutable-default', topic: 'python', kind: 'fact', text: '`def f(items=[])` creates the list once and shares it across calls. Default to None and create it inside.' },
6 { id: 'py-is', topic: 'python', kind: 'fact', text: '`is` checks identity, `==` checks equality. Compare with None using `is None`.' },
7 { id: 'py-fstring-equals', topic: 'python', kind: 'fact', text: '`f"{x=}"` prints both the name and the value, like `x=42` (Python 3.8+).' },
8 { id: 'py-dict-order', topic: 'python', kind: 'fact', text: 'Dicts keep insertion order; the language guarantees it since Python 3.7.' },
9 { id: 'py-enumerate-start', topic: 'python', kind: 'fact', text: '`enumerate(items, start=1)` numbers items from 1 instead of 0.' },
10 { id: 'py-zip-strict', topic: 'python', kind: 'fact', text: '`zip(a, b, strict=True)` raises if the lengths differ, instead of silently stopping early (3.10+).' },
11 { id: 'py-pathlib', topic: 'python', kind: 'fact', text: '`pathlib` joins paths with `/`: `Path("src") / "app.py"`.' },
12 { id: 'py-counter', topic: 'python', kind: 'fact', text: '`Counter("banana").most_common(1)` gives `[("a", 3)]`.' },
13 { id: 'py-walrus', topic: 'python', kind: 'fact', text: '`while (line := f.readline()):` assigns and tests in one step, with the walrus operator (3.8+).' },
14 { id: 'py-match', topic: 'python', kind: 'fact', text: '`match`/`case` does structural pattern matching on shapes like `case {"type": "user", "id": id}:` (3.10+).' },
15 { id: 'py-comprehension-scope', topic: 'python', kind: 'fact', text: 'A list comprehension has its own scope: its loop variable does not leak out (Python 3).' },
16 { id: 'py-lru-cache', topic: 'python', kind: 'fact', text: '`@functools.cache` remembers results by argument; the arguments must be hashable.' },
17 { id: 'py-slots', topic: 'python', kind: 'fact', text: '`__slots__` stops each instance from getting a `__dict__`, which saves memory for many small objects.' },
18 { id: 'py-with', topic: 'python', kind: 'fact', text: '`with open(p) as f:` closes the file even when the block raises an exception.' },
19 { id: 'py-generators', topic: 'python', kind: 'fact', text: 'A function with `yield` is a generator: it produces values one at a time, only when asked.' },
20 { id: 'py-sort-stable', topic: 'python', kind: 'fact', text: '`sorted()` is stable: items with equal keys keep their original order, so you can sort in passes.' },
21 { id: 'py-fixture-yield', topic: 'python', kind: 'fact', text: 'A pytest fixture that uses `yield` runs the code after the yield as teardown, once the test ends.' },
22 { id: 'py-typeddict', topic: 'python', kind: 'fact', text: '`TypedDict` describes the keys of a dict for type checkers; nothing is checked at runtime.' },
23 {
24 id: 'py-float', topic: 'python', kind: 'yesno', answer: true,
25 text: 'Is `0.1 + 0.2` greater than `0.3`?',
26 explain: 'Yes. Binary floating point gives 0.30000000000000004, so `== 0.3` is false. Compare with `math.isclose(a, b)`.',
27 },
28 {
29 id: 'py-one-tuple', topic: 'python', kind: 'yesno', answer: false,
30 text: 'Is `(1)` a tuple with one element?',
31 explain: 'It is just the number 1 in brackets. A one-element tuple needs a comma: `(1,)`.',
32 },
33 {
34 id: 'py-list-key', topic: 'python', kind: 'yesno', answer: false,
35 text: 'Can a list be used as a dict key?',
36 explain: 'No, lists are mutable and unhashable. Use a tuple instead.',
37 },
38 {
39 id: 'py-reverse-slice', topic: 'python', kind: 'yesno', answer: true,
40 text: 'Does `[1, 2, 3][::-1]` return a reversed copy?',
41 explain: 'Yes, a step of -1 walks the list backwards and builds a new list.',
42 },
43 {
44 id: 'py-round-half', topic: 'python', kind: 'yesno', answer: true,
45 text: 'Does `round(2.5)` return 2?',
46 explain: 'Yes. Python rounds a half to the nearest even number ("banker\'s rounding"), so `round(3.5)` is 4.',
47 },
48 {
49 id: 'py-string-repeat', topic: 'python', kind: 'yesno', answer: true,
50 text: 'Does `"ab" * 2` give `"abab"`?',
51 explain: 'Yes, multiplying a string repeats it.',
52 },
53 {
54 id: 'py-gil', topic: 'python', kind: 'yesno', answer: false,
55 text: 'In standard CPython, can two threads run Python bytecode at the same instant?',
56 explain: 'No, the GIL lets one thread run bytecode at a time. Free-threaded builds (3.13+) are the exception.',
57 },
58 {
59 id: 'py-chained-assign', topic: 'python', kind: 'yesno', answer: false,
60 text: 'Does `a = b = []` create two separate lists?',
61 explain: 'No. Both names point at the same list, so appending through one shows in the other.',
62 },
63 {
64 id: 'py-bool-int', topic: 'python', kind: 'yesno', answer: true,
65 text: 'Is `bool` a subclass of `int`?',
66 explain: 'Yes. `True == 1` and `sum([True, True])` is 2.',
67 },
68 {
69 id: 'py-dict-get', topic: 'python', kind: 'yesno', answer: false,
70 text: 'Does `d.get("missing")` raise a KeyError?',
71 explain: 'No, it returns None, or the default you pass: `d.get("k", 0)`. `d["missing"]` raises.',
72 },
73 {
74 id: 'py-keyboard-interrupt', topic: 'python', kind: 'yesno', answer: false,
75 text: 'Does `except Exception:` catch a KeyboardInterrupt?',
76 explain: 'No. KeyboardInterrupt derives from BaseException, not Exception, so Ctrl+C still stops the program.',
77 },
78 {
79 id: 'py-str-mutable', topic: 'python', kind: 'yesno', answer: false,
80 text: 'Can you change one character of a string in place, like `s[0] = "x"`?',
81 explain: 'No, strings are immutable. Build a new one: `"x" + s[1:]`.',
82 },
83 {
84 id: 'py-range-end', topic: 'python', kind: 'yesno', answer: true,
85 text: 'Does `range(5)` stop at 4?',
86 explain: 'Yes, it gives 0 to 4. The end of a range is excluded, so `range(5)` yields five values.',
87 },
88 {
89 id: 'py-shallow-copy', topic: 'python', kind: 'yesno', answer: false,
90 text: 'Does `copy.copy(outer)` also copy the lists nested inside `outer`?',
91 explain: 'No, it is shallow: the nested lists are shared. Use `copy.deepcopy`.',
92 },
93 {
94 id: 'py-sorted-in-place', topic: 'python', kind: 'yesno', answer: true,
95 text: 'Does `sorted(items)` leave `items` as it was?',
96 explain: 'Yes, it returns a new list. `items.sort()` is the one that sorts in place, and it returns None.',
97 },
98 {
99 id: 'py-empty-braces', topic: 'python', kind: 'yesno', answer: true,
100 text: 'Does `{}` create an empty dict?',
101 explain: 'Yes. `{}` is a dict, not a set; an empty set has to be written `set()`.',
102 },
103 {
104 id: 'py-generator-len', topic: 'python', kind: 'yesno', answer: false,
105 text: 'Can you call `len()` on a generator?',
106 explain: 'No, it raises TypeError: a generator does not know its length until it is used up.',
107 },
108 {
109 id: 'py-default-eval', topic: 'python', kind: 'yesno', answer: true,
110 text: 'Are default argument values evaluated once, when the function is defined?',
111 explain: 'Yes. That is why a mutable default like `[]` is shared between calls.',
112 },
113]
114types/index.d.ts 41 lines1/**
2 * A topic a card belongs to, as a lowercase slug such as `python`, `rust` or
3 * `kubernetes`. The set is open: the session's tool calls name the topic, and
4 * a generated card can carry one no pack ships.
5 */
6export type Topic = string
7
8/**
9 * One learning card. A `fact` is read and dismissed; a `yesno` is answered
10 * with 1 (yes) or 2 (no) and then explained.
11 */
12export type Card = {
13 id: string
14 topic: Topic
15 kind: 'fact' | 'yesno'
16 text: string
17 /** The right answer of a `yesno` card. */
18 answer?: boolean
19 /** One or two short sentences, shown after a `yesno` is answered. */
20 explain?: string
21 /** `pack` for a card this plugin ships, `ai` for one a model wrote. */
22 source?: 'pack' | 'ai'
23}
24
25/** What the band shows after a `yesno` card is answered. */
26export type Feedback = { isRight: boolean; text: string }
27
28/** Cards seen and answered right during the current Claude turn. */
29export type TurnTally = { seen: number; asked: number; right: number }
30
31declare module 'claude-code' {
32 interface PluginState {
33 holdtime: {
34 card: Card | null
35 feedback: Feedback | null
36 needsYou: boolean
37 tally: TurnTally
38 }
39 }
40}
41