[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…

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.
Add the marketplace once, then install the plugin:
/plugin marketplace add musingfox/cc-plugins
/plugin install flashcards
| Command | What it does |
|---|---|
/cards build | Build or refresh the deck from up to 20 changed sources |
/cards | Open 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.
hooks/register.ts 38 lines1import 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}
38hooks/build.ts 167 lines1import 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}
167hooks/pane-review.ts 200 lines1import 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}
200hooks/accept.ts 58 lines1import 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}
58hooks/deck-store.ts 82 lines1import 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}
82hooks/prompt.ts 39 lines1import 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}
39hooks/reconcile.ts 15 lines1import 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}
15hooks/reply.ts 40 lines1export 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}
40hooks/repo-sources.ts 50 lines1import 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}
50hooks/select.ts 23 lines1import 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}
23hooks/vault-sources.ts 54 lines1import 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}
54hooks/leitner.ts 11 lines1import 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