SLOPSHOPPER

Holdtime

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.

newbandguardcommandtoastmodel
v0.2.2MITupdated 2026-10-02ItsRohith-A/holdtime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · holdtime
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /holdtime ⎿ holdtime: 0/20 cards today. ⎿ holdtime: Questions answered: 0, right: 0 (-). ⎿ holdtime: By topic: nothing answered yet. ⎿ holdtime: Cards available: 108 shipped, 0 generated. ⎿ holdtime: `/holdtime pause` turns the cards off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Holdtime

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

Why

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:

  • Only while Claude works. Cards appear when a turn starts and disappear when it ends. There is no feed to scroll.
  • Claude always comes first. A permission prompt or a question from Claude hides the card at once; it comes back when Claude carries on.
  • About your work. Editing .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.
  • It sticks. Spaced repetition brings a missed question back the next day, and correct ones further and further apart.
  • It stops. At most 5 cards per turn and 20 per day by default.

Requirements

  • Claude Code v2.1.287 or later. Holdtime is a mod: it runs inside Claude Code and draws in Claude's own window. Check with claude --version.
  • A terminal (or the desktop app's Code tab). Nothing else to install.

Install

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

How to use it

KeyWhat it does
1Yes, or "Got it" on a fact, or "Next" after an answer
2No
9Hide 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.

Commands

CommandWhat it does
/holdtimeToday's count, your accuracy, and accuracy by topic
/holdtime pauseTurn cards off (stays off across sessions)
/holdtime resumeTurn them back on
/holdtime resetDelete your saved progress and start fresh

Settings

Change these in /config, or when you install:

SettingDefaultWhat it does
Write new cards with a modelOnCards for topics beyond the three packs. Uses your Claude plan; see Privacy
Daily goal20Cards per day before Holdtime goes quiet
Cards per turn5Most cards in one Claude turn

What's inside

108 cards ship with Holdtime, in three topics, half quick facts and half yes/no questions:

TopicCardsPicked when Claude…
JavaScript and TypeScript36edits .js/.ts files, runs npm, node, tsc…
Python36edits .py files, runs python, pytest, pip…
Git36runs 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.

Cards for anything else

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.

Privacy

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.

Every event Holdtime handles

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:

EventWhat it readsWhat it changes
session.startWhether a few project files exist, to pick the first topicNothing. Registers /holdtime and loads your saved progress
command.runOnly /holdtime: a matcher limits the hook to that one command, so no other command reaches itNothing. It answers its own command with the text you see
turn.startThat a turn beganNothing. Picks the first card
tool.callThe tool's name, a file's extension, the first word of a shell commandNothing. The call and its result pass through untouched; it never denies, delays or alters a tool call
classic.PermissionRequestThe tool's name and the one field identifying the callNothing — see below
classic.NotificationThat Claude has asked you somethingNothing. Passes the notification on untouched
turn.completeThe turn's answer and how many cards you sawAdds one line beneath Claude's answer, the Holdtime: 2 cards this turn… summary. Claude's answer itself is untouched
ui.renderThe band's width, and whether Claude is workingDraws 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.

The permission hook decides nothing

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.

Troubleshooting

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.

Development

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.

License

MIT — see LICENSE.

Source 9 files
hooks/register.tsx 472 lines
1/**
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}
472
content/index.ts 8 lines
1import 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]
8
content/generate.ts 124 lines
1/**
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}
124
hooks/planner.ts 375 lines
1/**
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}
375
hooks/pool.ts 71 lines
1/**
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}
71
content/git.ts 114 lines
1import 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]
114
content/javascript.ts 114 lines
1import 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]
114
content/python.ts 114 lines
1import 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]
114
types/index.d.ts 41 lines
1/**
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