SLOPSHOPPER

flashcards

[Experimental] Flashcards from your repo's specs, ADRs and vault task cards — /cards build generates a review deck with a background Haiku, /cards opens a…

newpanecommandtoastprocess
★ 2v0.1.0MITupdated 2026-10-15musingfox/cc-plugins/flashcards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flashcards
│ ┃ Flashcards ✕ › fix the failing auth test and add an audit log call │ ┃ 這個 repo 還沒有牌組。先跑 /cards build。 │ ┃ q: 關閉 ⏺ 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 │ │ › /cards │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Flashcards
這個 repo 還沒有牌組。先跑 /cards build。 q: 關閉
README

flashcards

Review cards generated from your repo's documents, as a Claude Mod (hooks/register.ts). /cards build sends the changed spec and ADR documents, and the done task cards of the Obsidian project configured in .obsidian.yaml, to a background Haiku in batches of 10. /cards opens a review pane that deals the due cards with a Leitner schedule; it runs without model tokens.

Install

Add the marketplace once, then install the plugin:

/plugin marketplace add musingfox/cc-plugins
/plugin install flashcards
CommandWhat it does
/cards buildBuild or refresh the deck from up to 20 changed sources
/cardsOpen the review pane

In the pane: f flips the card, 1 / 2 / 3 rate it (forgot / hazy / remembered), q closes it.

The deck and review progress are kept in Claude Code's store, keyed by the repo root, not in the repo.

Source 19 files
hooks/register.ts 38 lines
1import type { Register } from 'claude-code'
2import { registerBuild, resetBuild, startBuild } from './build.ts'
3import { openReview, registerReview } from './pane-review.ts'
4
5export const COMMAND = {
6  name: 'cards',
7  description: 'Open the flashcard review pane, or build the deck in the background with /cards build',
8  argumentHint: '[build]',
9  immediate: true,
10} as const
11
12// The engine takes one unmatched session.start per plugin and never follows $ across an
13// import, so the command and the build's session state register here.
14export const register: Register = (on) => {
15  on('session.start', async ($, e, next) => {
16    try {
17      await $.command.register(COMMAND)
18    } catch {
19      // A refused command leaves the deck unreachable from the prompt; the plugin keeps loading.
20    }
21    resetBuild()
22    return next(e)
23  })
24  on('command.run', { command: 'cards' }, async ($, e) => {
25    const args = e.args.trim()
26    try {
27      if (args === '') await openReview($)
28      else if (args === 'build') await startBuild($)
29      else await $.ui.toast('usage: /cards [build]')
30    } catch {
31      // A refused pane or toast leaves nothing to show; the command still answers the prompt with {}.
32    }
33    return {}
34  })
35  registerReview(on)
36  registerBuild(on)
37}
38
hooks/build.ts 167 lines
1import type { EngineInterface, On } from 'claude-code'
2import { acceptCards } from './accept.ts'
3import { loadDeck, loadReview, repoRoot, saveDeck, saveReview } from './deck-store.ts'
4import { batchPrompt } from './prompt.ts'
5import { reconcile } from './reconcile.ts'
6import { parseReply } from './reply.ts'
7import { repoSources } from './repo-sources.ts'
8import { selectBuild } from './select.ts'
9import type { Deck, Source } from '../types'
10import { vaultSources } from './vault-sources.ts'
11
12const UNREADABLE = 'Flashcards deck could not be read; build stopped so it is not overwritten.'
13const UNSAVED = 'Flashcards deck could not be saved; build stopped.'
14const NO_SOURCES = 'No sources found. Add docs/spec/*.md, an ADR directory, or a .obsidian.yaml with pm.project.'
15
16const GUARD_MS = 30 * 60 * 1000
17const BUSY = 'A flashcards build is already running; wait for its report.'
18
19// Set from the build's start until its report, or until a build stops before it spawns anything.
20let inFlight: { startedAt: number } | null = null
21
22// One /cards build: its batches settle into this tally, and the last settle reports it.
23type Build = {
24  root: string
25  outstanding: number
26  added: number
27  dropped: number
28  failedSources: number
29  recordedSources: number
30  pendingBefore: number
31}
32
33type Batch = { build: Build; sources: Source[] }
34
35// The batch each background agent runs, by the agent's id.
36const tracked = new Map<string, Batch>()
37
38export function resetBuild() {
39  inFlight = null
40}
41
42// The command returns at once; the continuation reads the repo and vault and prunes the deck in the background.
43// A second build inside the guard window is refused, so two builds never spend tokens on the same sources.
44export async function startBuild($: EngineInterface) {
45  const now = await $.clock.now()
46  if (inFlight && now - inFlight.startedAt < GUARD_MS) return $.ui.toast(BUSY)
47  inFlight = { startedAt: now }
48  void continueBuild($).catch((error: unknown) => stop($, `Flashcards build stopped: ${error instanceof Error ? error.message : String(error)}`).catch(() => {}))
49}
50
51async function stop($: EngineInterface, text: string) {
52  inFlight = null
53  await $.ui.toast(text)
54}
55
56async function continueBuild($: EngineInterface) {
57  const root = await repoRoot($)
58  const deck = await loadDeck($, root)
59  if (!deck) return stop($, UNREADABLE)
60  const repo = await repoSources($, root)
61  const vault = await vaultSources($)
62  if (vault.status === 'error') await $.ui.toast(`Vault task cards skipped: ${vault.message}`)
63  const { deck: reconciled, removedCardIds } = reconcile(deck, repo, vault)
64  if (!(await saveDeck($, root, reconciled))) return stop($, UNSAVED)
65  if (removedCardIds.length) {
66    const review = await loadReview($, root)
67    for (const id of removedCardIds) delete review[id]
68    await saveReview($, root, review)
69  }
70  await startBatches($, root, reconciled, [...repo.sources, ...vault.sources])
71}
72
73// Every agent id a batch has been given, kept after the batch merges: a finished agent must not be claimed again.
74const claimed = new Set<string>()
75
76async function unclaimedAgent($: EngineInterface): Promise<string | undefined> {
77  const agents = await $.agent.list()
78  return agents.find((agent) => agent.spawnedBy === 'flashcards' && !claimed.has(agent.id))?.id
79}
80
81// The last batch to settle reports the build: what was added, dropped and failed, and what is still pending.
82async function settle($: EngineInterface, batch: Batch, outcome: { added: number; dropped: number; recorded: number } | 'failed') {
83  const { build } = batch
84  build.outstanding -= 1
85  if (outcome === 'failed') {
86    build.failedSources += batch.sources.length
87  } else {
88    build.added += outcome.added
89    build.dropped += outcome.dropped
90    build.recordedSources += outcome.recorded
91  }
92  if (build.outstanding === 0) {
93    inFlight = null
94    const remaining = build.pendingBefore - build.recordedSources
95    const parts = [`${build.added} cards added`]
96    if (build.dropped) parts.push(`${build.dropped} rejected by the checks`)
97    if (build.failedSources) parts.push(`${build.failedSources} sources failed and wait for the next build`)
98    parts.push(`${remaining} sources remain`)
99    await $.ui.toast(`Flashcards: ${parts.join(', ')}.`)
100    await $.ui.invalidate('ui.render')
101  }
102}
103
104async function spawnBatch($: EngineInterface, batch: Batch): Promise<void> {
105  let result: { agentId?: string; deny?: string }
106  try {
107    result = await $.agent.spawn({ prompt: batchPrompt(batch.sources), model: 'haiku', description: `Flashcards: ${batch.sources.length} sources` })
108  } catch {
109    return settle($, batch, 'failed')
110  }
111  if (result.deny) return settle($, batch, 'failed')
112  const agentId = result.agentId ?? (await unclaimedAgent($))
113  if (!agentId) return settle($, batch, 'failed')
114  claimed.add(agentId)
115  tracked.set(agentId, batch)
116}
117
118async function startBatches($: EngineInterface, root: string, deck: Deck, sources: Source[]) {
119  if (sources.length === 0) return stop($, NO_SOURCES)
120  const { batches, pendingCount, remaining } = selectBuild(deck, sources)
121  if (batches.length === 0) return stop($, `Flashcards deck is up to date: ${deck.cards.length} cards from ${sources.length} sources.`)
122  const selected = batches.reduce((count, batch) => count + batch.length, 0)
123  await $.ui.toast(`Building flashcards from ${selected} sources in ${batches.length} batch(es) in the background; ${remaining} more sources wait for a later build.`)
124  const build: Build = { root, outstanding: batches.length, added: 0, dropped: 0, failedSources: 0, recordedSources: 0, pendingBefore: pendingCount }
125  for (const sources of batches) await spawnBatch($, { build, sources: sources })
126}
127
128// A reply is merged only when it parses and the stored deck can be read; otherwise the batch's sources stay pending.
129async function merge($: EngineInterface, batch: Batch, reply: { reason: string; answer: string }) {
130  const { root } = batch.build
131  const raw = reply.reason === 'answer' ? parseReply(reply.answer) : null
132  const deck = raw ? await loadDeck($, root) : null
133  if (!raw || !deck) return settle($, batch, 'failed')
134  const { accepted, dropped } = await acceptCards(raw, batch.sources)
135  // A source the model gave cards for, all of them rejected, stays pending: only a source with an accepted card,
136  // or one the model judged had nothing worth a card, is recorded at its hash.
137  const recorded = batch.sources.filter((source) => accepted.some((card) => card.sourcePath === source.path) || !raw.some((card) => card.source === source.path))
138  const paths = new Set(recorded.map((source) => source.path))
139  const replacedIds = deck.cards.filter((card) => paths.has(card.sourcePath)).map((card) => card.id)
140  const next: Deck = {
141    ...deck,
142    sources: { ...deck.sources, ...Object.fromEntries(recorded.map((source) => [source.path, { hash: source.hash, type: source.type }])) },
143    cards: [...deck.cards.filter((card) => !paths.has(card.sourcePath)), ...accepted],
144  }
145  if (!(await saveDeck($, root, next))) return settle($, batch, 'failed')
146  if (replacedIds.length) {
147    const review = await loadReview($, root)
148    for (const id of replacedIds) delete review[id]
149    await saveReview($, root, review)
150  }
151  await settle($, batch, { added: accepted.length, dropped: dropped.length, recorded: recorded.length })
152}
153
154// Merges run one at a time: each reads the stored deck, so two batches that finish together would lose one write.
155let merging: Promise<void> = Promise.resolve()
156
157export function registerBuild(on: On) {
158  on('turn.complete', async ($, e, next) => {
159    const batch = e.agentId === undefined ? undefined : tracked.get(e.agentId)
160    if (batch && e.agentId !== undefined) {
161      tracked.delete(e.agentId)
162      await (merging = merging.then(() => merge($, batch, e)).catch(() => {}))
163    }
164    return next(e)
165  })
166}
167
hooks/pane-review.ts 200 lines
1import type { EngineInterface, On } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import { loadDeck, loadReview, repoRoot, saveReview } from './deck-store.ts'
4import { INTERVAL, rate } from './leitner.ts'
5import { dueQueue } from './queue.ts'
6import { drawable } from './text.ts'
7import type { Box, Card, Kind, Review } from '../types'
8
9const PANE = 'cards'
10const CARD_DEAL_ERROR = '讀不到這個 repo 的牌組(Claude store 拒絕讀取)。'
11const NO_DECK = '這個 repo 還沒有牌組。先跑 /cards build。'
12const KEYS = 'Esc 把按鍵交回輸入框,再跑 /cards 拿回來。'
13const KIND_LABEL: Record<Kind, string> = { rule: '規則', why: '為什麼', 'what-for': '用途' }
14const DAY = 86_400_000
15
16const queue = atom({ plugin: 'flashcards', key: 'queue' } as const, [] as string[])
17const index = atom({ plugin: 'flashcards', key: 'index' } as const, 0)
18const flipped = atom({ plugin: 'flashcards', key: 'flipped' } as const, false)
19const reviewed = atom({ plugin: 'flashcards', key: 'reviewed' } as const, 0)
20const tally = atom({ plugin: 'flashcards', key: 'tally' } as const, [0, 0, 0] as number[])
21
22type Elements = { Box: any; Text: any; Button: any }
23
24const RATINGS = [
25  { rating: 1, label: '忘了' },
26  { rating: 2, label: '模糊' },
27  { rating: 3, label: '記得' },
28] as const
29
30// When a card filed in `box` comes back, in words.
31function whenLabel(box: Box): string {
32  const days = INTERVAL[box] / DAY
33  return days === 0 ? '今天' : days === 1 ? '明天' : `${days} 天後`
34}
35
36// How long until `due`, in words, rounded up.
37function untilLabel(due: number, now: number): string {
38  const minutes = Math.max(1, Math.ceil((due - now) / 60_000))
39  if (minutes < 60) return `${minutes} 分鐘後`
40  const hours = Math.ceil(minutes / 60)
41  return hours < 24 ? `${hours} 小時後` : `${Math.ceil(hours / 24)} 天後`
42}
43
44const nameOf = (path: string) => path.split('/').pop()!.replace(/\.md$/, '')
45
46const boxBar = (counts: number[]) => counts.map((count, i) => `第 ${i + 1} 格 ${count}`).join(' · ')
47
48// The repo the open pane reviews, and its review progress, which this session keeps even when the store refuses a write.
49let session: { root: string; review: Review } | null = null
50
51// The first queued card still in the deck from `from` on, with its position in the queue.
52function locate(queueIds: string[], from: number, cards: Card[]): { at: number; card: Card } | null {
53  for (let at = from; at < queueIds.length; at++) {
54    const card = cards.find((candidate) => candidate.id === queueIds[at])
55    if (card) return { at, card }
56  }
57  return null
58}
59
60function boxOf(review: Review, card: Card): number {
61  return review[card.id]?.box ?? 1
62}
63
64// Shows the answer of the card on screen; a card that is no longer in the deck is skipped first.
65async function flip($: EngineInterface) {
66  if (!session) return
67  const deck = await loadDeck($, session.root)
68  const at = locate(await read($, queue), await read($, index), deck?.cards ?? [])
69  if (!at) return
70  await update($, index, () => at.at)
71  await update($, flipped, () => true)
72}
73
74// Files the flipped card in its new box, saves the review, and deals the next card unflipped.
75// A store that refuses the write keeps the progress for this session only.
76async function rateCard($: EngineInterface, rating: 1 | 2 | 3) {
77  if (!session || !(await read($, flipped))) return
78  const deck = await loadDeck($, session.root)
79  const at = locate(await read($, queue), await read($, index), deck?.cards ?? [])
80  if (!at) return
81  const now = await $.clock.now()
82  session.review = { ...session.review, [at.card.id]: rate(session.review[at.card.id], rating, now) }
83  await saveReview($, session.root, session.review)
84  await update($, index, () => at.at + 1)
85  await update($, flipped, () => false)
86  await update($, reviewed, (count) => count + 1)
87  await update($, tally, (counts) => counts.map((count, i) => (i === rating - 1 ? count + 1 : count)))
88  await $.ui.invalidate('ui.render')
89}
90
91export async function openReview($: EngineInterface) {
92  const root = await repoRoot($)
93  const deck = await loadDeck($, root)
94  const review = await loadReview($, root)
95  session = { root, review }
96  const now = await $.clock.now()
97  await update($, queue, () => (deck ? dueQueue(deck.cards, review, now) : []))
98  await update($, index, () => 0)
99  await update($, flipped, () => false)
100  await update($, reviewed, () => 0)
101  await update($, tally, () => [0, 0, 0])
102  await $.ui.open({ id: PANE, title: 'Flashcards', focus: true })
103}
104
105// Every screen ends with the close key, which takes the pane down whatever it shows.
106async function screen($: EngineInterface, { Box, Text, Button }: Elements) {
107  const deck = session ? await loadDeck($, session.root) : null
108  if (!deck) return Text({ color: 'error', children: [CARD_DEAL_ERROR] })
109  if (deck.cards.length === 0) return Text({ dimColor: true, children: [NO_DECK] })
110  const review = session!.review
111  const boxes = [1, 2, 3].map((box) => deck.cards.filter((card) => boxOf(review, card) === box).length)
112  const now = await $.clock.now()
113  const nextDue = () => {
114    const dues = deck.cards.map((card) => review[card.id]?.due ?? 0).filter((due) => due > now)
115    return dues.length ? `下一張 ${untilLabel(Math.min(...dues), now)}到期。` : ''
116  }
117  const queueIds = await read($, queue)
118  if (queueIds.length === 0) {
119    return Box({
120      flexDirection: 'column',
121      children: [
122        Text({ dimColor: true, children: [drawable(`現在沒有到期的卡。${nextDue()}`)] }),
123        Text({ dimColor: true, children: [drawable(`共 ${deck.cards.length} 張:${boxBar(boxes)}`)] }),
124      ],
125    })
126  }
127  const at = locate(queueIds, await read($, index), deck.cards)
128  if (!at) {
129    const [forgot, fuzzy, known] = await read($, tally)
130    return Box({
131      flexDirection: 'column',
132      children: [
133        Text({ bold: true, children: [drawable(`這輪複習完 ${await read($, reviewed)} 張:記得 ${known}、模糊 ${fuzzy}、忘了 ${forgot}。`)] }),
134        Text({ dimColor: true, children: [drawable(`${nextDue() || '再跑 /cards 看看有沒有新到期的卡。'}`)] }),
135        Text({ dimColor: true, children: [drawable(`共 ${deck.cards.length} 張:${boxBar(boxes)}`)] }),
136      ],
137    })
138  }
139  const card = at.card
140  const box = boxOf(review, card)
141  const isFlipped = await read($, flipped)
142  const label = (mark: string, text: string, key: string, color: string, marginTop = 0) =>
143    Box({
144      key: `${key}-row`,
145      flexDirection: 'row',
146      marginTop,
147      children: [Text({ bold: true, color, children: [`${mark}  `] }), Box({ key, flexShrink: 1, children: [Text({ wrap: 'wrap', children: [drawable(text)] })] })],
148    })
149  const nextBox = (rating: 1 | 2 | 3): Box => rate(review[card.id], rating, 0).box
150  return Box({
151    flexDirection: 'column',
152    children: [
153      Box({
154        key: 'progress',
155        flexDirection: 'row',
156        justifyContent: 'space-between',
157        children: [
158          Text({ dimColor: true, children: [drawable(`${KIND_LABEL[card.kind]} · ${nameOf(card.sourcePath)}`)] }),
159          Text({ dimColor: true, children: [drawable(`${at.at + 1}/${queueIds.length} · 第 ${box} 格`)] }),
160        ],
161      }),
162      Box({ key: 'gap', height: 1, children: [] }),
163      label('問', card.q, 'question', 'claude'),
164      ...(isFlipped
165        ? [
166            label('答', card.a, 'answer', 'success', 1),
167            Box({ key: 'quote', paddingLeft: 4, children: [Text({ italic: true, dimColor: true, wrap: 'wrap', children: [drawable(`“${card.quote}”`)] })] }),
168            Box({ key: 'source', paddingLeft: 4, children: [Text({ dimColor: true, children: [drawable(`${deck.sources[card.sourcePath]?.type === 'task' ? 'Obsidian vault' : 'repo'} · ${card.sourcePath}`)] })] }),
169          ]
170        : []),
171      Box({
172        key: 'actions',
173        marginTop: 1,
174        flexDirection: 'row',
175        gap: 2,
176        children: isFlipped
177          ? RATINGS.map((item) =>
178              Button({ key: `rate:${item.rating}`, label: `${item.label}·${whenLabel(nextBox(item.rating))}`, hotkey: String(item.rating), plain: true, onPress: () => rateCard($, item.rating) }),
179            )
180          : [Button({ key: 'flip', label: '翻面', hotkey: 'f', plain: true, onPress: () => flip($) })],
181      }),
182      Text({ dimColor: true, children: [KEYS] }),
183    ],
184  })
185}
186
187export function registerReview(on: On) {
188  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
189    const elements = $.ui.resolve(e)
190    const { Box, Button } = elements
191    return Box({
192      flexDirection: 'column',
193      children: [
194        await screen($, elements),
195        Button({ key: 'close', label: '關閉', hotkey: 'q', plain: true, onPress: () => $.ui.close({ id: PANE }) }),
196      ],
197    })
198  })
199}
200
hooks/accept.ts 58 lines
1import type { Card, Kind, Source } from '../types'
2import { hashText } from './hash.ts'
3import { KINDS } from './prompt.ts'
4import type { RawCard } from './reply.ts'
5
6export type DropReason = 'unknown-source' | 'kind' | 'fields' | 'quote' | 'duplicate' | 'over-limit'
7
8const PER_SOURCE_LIMIT = 3
9
10// Sources hard-wrap mid-sentence and a model may re-flow a quote, so the check compares with runs of whitespace collapsed.
11const squash = (value: string) => value.replace(/\s+/g, ' ').trim()
12
13const text = (value: unknown) => (typeof value === 'string' && value !== '' ? value : null)
14
15// Async because the card id is a SHA-256, which only crypto.subtle computes.
16export async function acceptCards(raw: RawCard[], batch: Source[]): Promise<{ accepted: Card[]; dropped: { reason: DropReason }[] }> {
17  const accepted: Card[] = []
18  const dropped: { reason: DropReason }[] = []
19  const ids = new Set<string>()
20  const perSource = new Map<string, number>()
21  for (const card of raw) {
22    const source = batch.find((candidate) => candidate.path === card.source)
23    if (!source) {
24      dropped.push({ reason: 'unknown-source' })
25      continue
26    }
27    const q = text(card.q)
28    const a = text(card.a)
29    const quote = text(card.quote)
30    if (!q || !a || !quote) {
31      dropped.push({ reason: 'fields' })
32      continue
33    }
34    if (!KINDS[source.type].includes(card.kind as Kind)) {
35      dropped.push({ reason: 'kind' })
36      continue
37    }
38    if (!squash(source.text).includes(squash(quote))) {
39      dropped.push({ reason: 'quote' })
40      continue
41    }
42    const id = (await hashText(`${source.path}\n${q}`)).slice(0, 16)
43    if (ids.has(id)) {
44      dropped.push({ reason: 'duplicate' })
45      continue
46    }
47    const count = perSource.get(source.path) ?? 0
48    if (count >= PER_SOURCE_LIMIT) {
49      dropped.push({ reason: 'over-limit' })
50      continue
51    }
52    ids.add(id)
53    perSource.set(source.path, count + 1)
54    accepted.push({ id, sourcePath: source.path, sourceHash: source.hash, kind: card.kind as Kind, q, a, quote })
55  }
56  return { accepted, dropped }
57}
58
hooks/deck-store.ts 82 lines
1import type { EngineInterface } from 'claude-code'
2import { hashText } from './hash.ts'
3import { KINDS } from './prompt.ts'
4import type { Card, Deck, Review } from '../types'
5
6const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
7
8export async function repoKey(root: string): Promise<string> {
9  return (await hashText(root)).slice(0, 16)
10}
11
12const KIND_SET = new Set<string>(Object.values(KINDS).flat())
13const CARD_TEXT_FIELDS = ['id', 'sourcePath', 'sourceHash', 'q', 'a', 'quote'] as const satisfies readonly (keyof Card)[]
14
15// A card the pane can draw and the build can merge: every text field a string, and a known kind.
16const isCard = (value: unknown) => isRecord(value) && CARD_TEXT_FIELDS.every((key) => typeof value[key] === 'string') && KIND_SET.has(value.kind as string)
17
18// A source record the build can compare: a string hash and a known source type.
19const isSourceRecord = (value: unknown) => isRecord(value) && typeof value.hash === 'string' && Object.hasOwn(KINDS, value.type as string)
20
21// The store answers nothing for an absent key; a value of another shape or version, or a card with a bad field, is not a deck.
22function deckOf(value: unknown): Deck | null {
23  if (value === undefined || value === null) return { v: 1, sources: {}, cards: [] }
24  if (!isRecord(value) || value.v !== 1 || !isRecord(value.sources) || !Object.values(value.sources).every(isSourceRecord) || !Array.isArray(value.cards) || !value.cards.every(isCard)) return null
25  return value as unknown as Deck
26}
27
28function reviewOf(value: unknown): Review {
29  if (value === undefined || value === null) return {}
30  if (!isRecord(value)) return {}
31  const valid = Object.values(value).every((entry) => isRecord(entry) && [1, 2, 3].includes(entry.box as number) && typeof entry.due === 'number')
32  return valid ? (value as Review) : {}
33}
34
35export async function loadDeck($: EngineInterface, root: string): Promise<Deck | null> {
36  try {
37    return deckOf(await $.store.get(`deck:${await repoKey(root)}`))
38  } catch {
39    return null
40  }
41}
42
43export async function saveDeck($: EngineInterface, root: string, deck: Deck): Promise<boolean> {
44  try {
45    await $.store.set(`deck:${await repoKey(root)}`, deck)
46    return true
47  } catch {
48    return false
49  }
50}
51
52export async function loadReview($: EngineInterface, root: string): Promise<Review> {
53  try {
54    return reviewOf(await $.store.get(`review:${await repoKey(root)}`))
55  } catch {
56    return {}
57  }
58}
59
60export async function saveReview($: EngineInterface, root: string, review: Review): Promise<boolean> {
61  try {
62    await $.store.set(`review:${await repoKey(root)}`, review)
63    return true
64  } catch {
65    return false
66  }
67}
68
69// The repo's root as the session knows it: the repo, then the session's root, then its working directory.
70export async function repoRoot($: EngineInterface): Promise<string> {
71  const lookups = [async () => (await $.session.repo())?.root, () => $.session.root(), () => $.session.cwd()]
72  for (const lookup of lookups) {
73    try {
74      const root = await lookup()
75      if (root) return root
76    } catch {
77      // This lookup failed; the next one answers.
78    }
79  }
80  return ''
81}
82
hooks/prompt.ts 39 lines
1import type { Kind, Source, SourceType } from '../types'
2
3export const KINDS: Record<SourceType, Kind[]> = { spec: ['rule'], adr: ['why'], task: ['why', 'what-for'] }
4
5function blockOf(source: Source): string {
6  return [
7    `=== SOURCE path="${source.path}" type="${source.type}" kinds="${KINDS[source.type].join(', ')}" ===`,
8    source.text,
9    '=== END SOURCE ===',
10  ].join('\n')
11}
12
13// Self-contained so the subagent needs no tools: the rules, the output shape and every source inline.
14export function batchPrompt(sources: Source[]): string {
15  return [
16    'You write review flashcards so a developer can remember what their own project built and why.',
17    '',
18    'Input: the sources delimited below. Each is one source: a finished task card, an architecture spec, or a decision record.',
19    'Output: reply with only a JSON array, one element per card, and no prose before or after it:',
20    '[{ "source": "<the path given in that source\'s header>", "kind": "why" | "what-for" | "rule", "q": "...", "a": "...", "quote": "<verbatim sentence from the source that supports the answer>" }]',
21    '',
22    'Rules:',
23    '- 1 to 3 cards per source. Fewer good cards beat more weak ones; skip a source with nothing worth remembering.',
24    '- Use only the kinds the source header allows: a spec takes rule, a decision record takes why, a task takes why or what-for.',
25    '  - why: why was it decided this way, what was rejected, what went wrong before.',
26    '  - what-for: what problem does this feature solve, for whom.',
27    '  - rule: what must never happen or must always hold, and the reason.',
28    '- Never ask for names, file paths, version numbers, dates, counts, or command spellings. Never ask a question whose answer is a single identifier.',
29    '- The question must stand alone: name the plugin or feature it is about, so it makes sense shuffled among others.',
30    '- The answer is one or two short sentences, answerable from the source alone. Do not add outside knowledge.',
31    '- "quote" must be copied verbatim from its source; if you cannot find one, drop the card.',
32    '- Write q and a in Traditional Chinese, with technical terms in English. Keep quote in the source language.',
33    '',
34    'Use no tools. Everything you need is below.',
35    '',
36    ...sources.map(blockOf),
37  ].join('\n')
38}
39
hooks/reconcile.ts 15 lines
1import type { Deck, Detection } from '../types'
2
3// A recorded source is gone only when its own detection answered ok and neither lists its path.
4export function reconcile(deck: Deck, repo: Detection, vault: Detection): { deck: Deck; removedCardIds: string[] } {
5  const gone = (path: string, type: string) => {
6    const detected = type === 'task' ? vault : repo
7    return detected.status === 'ok' && !detected.sources.some((source) => source.path === path) && !detected.unreadable.includes(path)
8  }
9  const removed = new Set(Object.entries(deck.sources).filter(([path, record]) => gone(path, record.type)).map(([path]) => path))
10  const sources = Object.fromEntries(Object.entries(deck.sources).filter(([path]) => !removed.has(path)))
11  const cards = deck.cards.filter((card) => !removed.has(card.sourcePath))
12  const removedCardIds = deck.cards.filter((card) => removed.has(card.sourcePath)).map((card) => card.id)
13  return { deck: { ...deck, sources, cards }, removedCardIds }
14}
15
hooks/reply.ts 40 lines
1export type RawCard = Record<string, unknown>
2
3// The index of the bracket closing the one at `start`, skipping brackets inside JSON strings; -1 when it never closes.
4function closeOf(text: string, start: number): number {
5  let depth = 0
6  let inString = false
7  for (let i = start; i < text.length; i++) {
8    const c = text[i]
9    if (inString) {
10      if (c === '\\') i++
11      else if (c === '"') inString = false
12    } else if (c === '"') {
13      inString = true
14    } else if (c === '[') {
15      depth++
16    } else if (c === ']' && --depth === 0) {
17      return i
18    }
19  }
20  return -1
21}
22
23const isObject = (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value)
24
25// A subagent may wrap its array in prose or a fence, so the first bracketed span that parses as an array of objects wins.
26export function parseReply(answer: string): RawCard[] | null {
27  for (let start = answer.indexOf('['); start !== -1; start = answer.indexOf('[', start + 1)) {
28    const end = closeOf(answer, start)
29    if (end === -1) continue
30    let parsed: unknown
31    try {
32      parsed = JSON.parse(answer.slice(start, end + 1))
33    } catch {
34      continue
35    }
36    if (Array.isArray(parsed) && parsed.every(isObject)) return parsed as RawCard[]
37  }
38  return null
39}
40
hooks/repo-sources.ts 50 lines
1import type { EngineInterface } from 'claude-code'
2import { hashText, reviewableText } from './hash.ts'
3import type { Detection, Source, SourceType } from '../types'
4
5const SPEC_DIR = 'docs/spec'
6const ADR_DIRS = ['docs/decisions', 'docs/adr', 'adr', 'decisions']
7const SPEC_NAME = /\.md$/
8const ADR_NAME = /^[0-9]{4}-.*\.md$/
9
10function directories(): { dir: string; type: SourceType; name: RegExp }[] {
11  return [
12    { dir: SPEC_DIR, type: 'spec', name: SPEC_NAME },
13    ...ADR_DIRS.map((dir) => ({ dir, type: 'adr' as const, name: ADR_NAME })),
14  ]
15}
16
17// Only the files directly in each directory count; a directory that does not exist contributes nothing.
18export async function repoSources($: EngineInterface, root: string): Promise<Detection> {
19  const sources: Source[] = []
20  const unreadable: string[] = []
21  let failure: string | undefined
22  for (const { dir, type, name } of directories()) {
23    const absolute = `${root}/${dir}`
24    if (!(await $.fs.exists(absolute))) continue
25    let entries
26    try {
27      entries = await $.fs.list(absolute)
28    } catch (error) {
29      failure ??= error instanceof Error ? error.message : String(error)
30      continue
31    }
32    for (const entry of entries.filter((item) => item.kind === 'file' && name.test(item.name))) {
33      const path = `${dir}/${entry.name}`
34      let raw: string
35      try {
36        raw = await $.fs.read(`${absolute}/${entry.name}`)
37      } catch {
38        unreadable.push(path)
39        continue
40      }
41      const text = reviewableText(type, raw)
42      sources.push({ path, type, text, hash: await hashText(text), completed: null })
43    }
44  }
45  const byPath = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
46  sources.sort((a, b) => byPath(a.path, b.path))
47  unreadable.sort(byPath)
48  return failure ? { sources, unreadable, status: 'error', message: failure } : { sources, unreadable, status: 'ok' }
49}
50
hooks/select.ts 23 lines
1import type { Deck, Source } from '../types'
2
3// Specs and ADRs go first by path; tasks follow, newest completion first, undated last, then by path.
4function compare(a: Source, b: Source): number {
5  if (a.type !== 'task' || b.type !== 'task') {
6    if (a.type === 'task') return 1
7    if (b.type === 'task') return -1
8  } else if (a.completed !== b.completed) {
9    if (a.completed === null) return 1
10    if (b.completed === null) return -1
11    return a.completed < b.completed ? 1 : -1
12  }
13  return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
14}
15
16export function selectBuild(deck: Deck, sources: Source[], limit = 20, batchSize = 10): { batches: Source[][]; pendingCount: number; remaining: number } {
17  const pending = sources.filter((source) => deck.sources[source.path]?.hash !== source.hash)
18  const selected = [...pending].sort(compare).slice(0, limit)
19  const batches: Source[][] = []
20  for (let i = 0; i < selected.length; i += batchSize) batches.push(selected.slice(i, i + batchSize))
21  return { batches, pendingCount: pending.length, remaining: pending.length - selected.length }
22}
23
hooks/vault-sources.ts 54 lines
1import type { EngineInterface } from 'claude-code'
2import { hashText, reviewableText } from './hash.ts'
3import type { Detection, Source } from '../types'
4import { resolveVault } from './vault-config.ts'
5import { queryArgv, queryOutput, readArgv, readOutput, VAULT_TIMEOUT_MS } from './vault.ts'
6import type { Row, Run } from './vault.ts'
7
8async function runCli($: EngineInterface, argv: string[]): Promise<Run> {
9  try {
10    const result = await $.process.run(argv, { timeoutMs: VAULT_TIMEOUT_MS })
11    return { kind: 'exited', exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
12  } catch {
13    return { kind: 'rejected' }
14  }
15}
16
17// Newest completion first; a card with no completion date goes last; then by path.
18function byCompletion(a: Row, b: Row): number {
19  if (a.completed !== b.completed) {
20    if (a.completed === null) return 1
21    if (b.completed === null) return -1
22    return a.completed < b.completed ? 1 : -1
23  }
24  return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
25}
26
27export async function vaultSources($: EngineInterface): Promise<Detection> {
28  const config = await resolveVault($)
29  if (config.kind !== 'configured') return { sources: [], unreadable: [], status: 'unconfigured' }
30  const scope = { vault: config.vault, project: config.project }
31  const query = queryArgv(scope)
32  if ('refused' in query) return { sources: [], unreadable: [], status: 'error', message: `vault or project name refused: ${query.refused}` }
33  const listed = queryOutput(await runCli($, query.argv))
34  if (listed.kind === 'error') return { sources: [], unreadable: [], status: 'error', message: listed.message }
35  const rows = listed.kind === 'rows' ? listed.rows.filter((row) => row.status === 'done').sort(byCompletion) : []
36  const sources: Source[] = []
37  const unreadable: string[] = []
38  for (const row of rows) {
39    const read = readArgv(scope, row.path)
40    if ('refused' in read) {
41      unreadable.push(row.path)
42      continue
43    }
44    const card = readOutput(await runCli($, read.argv))
45    if (card.kind === 'error') {
46      unreadable.push(row.path)
47      continue
48    }
49    const text = reviewableText('task', card.text)
50    sources.push({ path: row.path, type: 'task', text, hash: await hashText(text), completed: row.completed })
51  }
52  return { sources, unreadable, status: 'ok' }
53}
54
hooks/leitner.ts 11 lines
1import type { Box } from '../types'
2
3// Days until a card in each box is due again, in milliseconds.
4export const INTERVAL: Record<Box, number> = { 1: 0, 2: 86_400_000, 3: 604_800_000 }
5
6export function rate(entry: { box: Box; due: number } | undefined, rating: 1 | 2 | 3, now: number): { box: Box; due: number } {
7  const current: Box = entry?.box ?? 1
8  const box: Box = rating === 1 ? 1 : rating === 2 ? current : (Math.min(current + 1, 3) as Box)
9  return { box, due: now + INTERVAL[box] }
10}
11