eklavya 1.51.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +13 -0
  2. package/dist/assets/dashboard.html +1 -1
  3. package/dist/assets/tutor/SKILL.md +18 -16
  4. package/dist/assets/tutor/references/panel.md +40 -0
  5. package/dist/cli.js +6 -3
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config-path.js +1 -0
  8. package/dist/config-path.js.map +1 -1
  9. package/dist/config.js +3 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +2 -0
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/hooks/checkpoint-quiz.js +27 -7
  14. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  15. package/dist/hooks/delegate-nudge.js +1 -1
  16. package/dist/hooks/delegate-nudge.js.map +1 -1
  17. package/dist/hooks/delegation-lib.js +14 -9
  18. package/dist/hooks/delegation-lib.js.map +1 -1
  19. package/dist/hooks/prompt-submit-nudge.js +1 -1
  20. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  21. package/dist/hooks/session-start.js +1 -1
  22. package/dist/hooks/session-start.js.map +1 -1
  23. package/dist/hooks/stop-quiz-check.js +6 -0
  24. package/dist/hooks/stop-quiz-check.js.map +1 -1
  25. package/dist/migrations/025_panel_questions.sql +43 -0
  26. package/dist/panel-state.js +117 -0
  27. package/dist/panel-state.js.map +1 -0
  28. package/dist/panel.js +241 -0
  29. package/dist/panel.js.map +1 -0
  30. package/dist/plugin/.claude-plugin/plugin.json +3 -2
  31. package/dist/plugin/agents/tutor.md +3 -1
  32. package/dist/plugin/hooks/CLAUDE.md +18 -0
  33. package/dist/plugin/hooks/hooks.json +44 -11
  34. package/dist/plugin/hooks/panel/model.ts +132 -0
  35. package/dist/plugin/hooks/panel/register.tsx +479 -0
  36. package/dist/plugin/hooks/panel/types/index.d.ts +61 -0
  37. package/dist/plugin/skills/CLAUDE.md +6 -0
  38. package/dist/plugin/skills/quiz/SKILL.md +3 -0
  39. package/dist/plugin/skills/tutor/SKILL.md +18 -16
  40. package/dist/plugin/skills/tutor/references/panel.md +40 -0
  41. package/dist/store.js +6 -0
  42. package/dist/store.js.map +1 -1
  43. package/dist/tools/config_tools.js +2 -1
  44. package/dist/tools/config_tools.js.map +1 -1
  45. package/dist/tools/get_session_quiz_plan.js +54 -8
  46. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  47. package/dist/tools/index.js +4 -0
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/panel_tools.js +72 -0
  50. package/dist/tools/panel_tools.js.map +1 -0
  51. package/dist/tools/record_attempt.js +191 -185
  52. package/dist/tools/record_attempt.js.map +1 -1
  53. package/dist/user-skill/eklavya/SKILL.md +1 -0
  54. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ -- Pending questions for the quiz side panel.
2
+ --
3
+ -- With the panel the model no longer asks and grades in one blocked turn: it
4
+ -- hands the question to `present_question` and goes back to work, and a Claude
5
+ -- Code mod (which has no database and loses its state on reload) shows it. So
6
+ -- the question, its answer key and the once-only guard need a transactional
7
+ -- home next to `attempts`.
8
+ --
9
+ -- `key` is the model's grading of each option ({correct_id, grades}). It is
10
+ -- never sent to the mod: the server grades from it.
11
+ --
12
+ -- `attempt_id` is set in the same transaction as the attempt row, and `result`
13
+ -- is the exact reply the pane was given, so a retried answer returns it
14
+ -- verbatim and writes nothing.
15
+ --
16
+ -- `phase`: pending (waiting), unplaced (the host could not seat the pane),
17
+ -- grading (reserved for a typed answer graded later), answered, skipped,
18
+ -- expired (24 hours unanswered; an unanswered question is not a decline).
19
+ CREATE TABLE IF NOT EXISTS panel_questions (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT NOT NULL,
22
+ repo TEXT NOT NULL,
23
+ concept_id INTEGER NOT NULL REFERENCES concepts(id),
24
+ tier INTEGER NOT NULL,
25
+ stem TEXT NOT NULL,
26
+ options TEXT NOT NULL,
27
+ key TEXT NOT NULL,
28
+ explanation TEXT NOT NULL,
29
+ -- 1 when the model has more questions queued in this round (an explicit
30
+ -- /eklavya:quiz): the pane offers "Next question" only then.
31
+ more INTEGER NOT NULL DEFAULT 0,
32
+ phase TEXT NOT NULL DEFAULT 'pending'
33
+ CHECK (phase IN ('pending','unplaced','grading','answered','skipped','expired')),
34
+ attempt_id INTEGER REFERENCES attempts(id),
35
+ result TEXT,
36
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
37
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
38
+ );
39
+
40
+ -- At most one open question per session, enforced where two callers cannot
41
+ -- get around it.
42
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_panel_questions_open
43
+ ON panel_questions(session_id) WHERE phase IN ('pending','unplaced','grading');
@@ -0,0 +1,117 @@
1
+ import { isCowork } from './surface.js';
2
+ /**
3
+ * What the hooks and the planner need to know about the side panel, and nothing
4
+ * else: whether this session's host showed up recently, and whether a question
5
+ * is already waiting. Kept apart from `panel.ts` so a hook can read it without
6
+ * loading the recorder (zod, the grading code) behind `panelAnswer`.
7
+ *
8
+ * The panel is a Claude Code mod, and a mod being installed proves nothing. The
9
+ * mod says it is alive by calling `panel_sync` on lifecycle events, which stamps
10
+ * a heartbeat here. A question is presented in the panel only when the setting
11
+ * is on and that heartbeat is fresh; any doubt keeps today's question card.
12
+ */
13
+ /** How long after its last `panel_sync` a mod still counts as present. */
14
+ export const PANEL_HEARTBEAT_TTL_MS = 90_000;
15
+ /**
16
+ * Host surfaces that have been seen to take an answer. Verified on the terminal
17
+ * only (docs/verified-schemas.md, Stage 0); the Desktop Code tab is added here
18
+ * when it has been tried, not before.
19
+ */
20
+ export const PANEL_SURFACES = ['terminal'];
21
+ /** The phases in which a question is still waiting for its answer. */
22
+ export const OPEN_PHASES = "('pending','unplaced','grading')";
23
+ /** An unanswered question stops being open after this long (`panel.ts` expires it). */
24
+ export const PANEL_EXPIRY_HOURS = 24;
25
+ const heartbeatKey = (sessionId) => `panel_hb:${sessionId}`;
26
+ /**
27
+ * Stamps the heartbeat for a session. `placed` is the mod's latest word on
28
+ * whether the host could seat the pane; a sync that carries none keeps the last.
29
+ */
30
+ export function recordHeartbeat(db, sessionId, host, placed, now = new Date()) {
31
+ const before = readHeartbeat(db, sessionId);
32
+ const beat = { at: now.toISOString(), surface: host.surface, placed: placed ?? before?.placed ?? true };
33
+ db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
34
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(heartbeatKey(sessionId), JSON.stringify(beat));
35
+ // A session's heartbeat means nothing a day later; drop the stale ones as they
36
+ // are written rather than let one row per session pile up.
37
+ db.prepare("DELETE FROM meta WHERE key LIKE 'panel_hb:%' AND json_extract(value, '$.at') < ?").run(new Date(now.getTime() - PANEL_EXPIRY_HOURS * 3600_000).toISOString());
38
+ }
39
+ function readHeartbeat(db, sessionId) {
40
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(heartbeatKey(sessionId));
41
+ return row ? JSON.parse(row.value) : null;
42
+ }
43
+ /**
44
+ * Where this session's next question goes. `panel` only when the setting is on,
45
+ * the host reported in within `PANEL_HEARTBEAT_TTL_MS` from a surface known to
46
+ * work, and its last placement did not fail. Everything else, including Cowork and a
47
+ * database that predates the panel, is the question card.
48
+ */
49
+ export function panelPresentation(db, config, sessionId, now = new Date()) {
50
+ if (!config.quiz.panel || isCowork())
51
+ return 'tool';
52
+ try {
53
+ const beat = readHeartbeat(db, sessionId);
54
+ if (!beat || !beat.placed || !PANEL_SURFACES.includes(beat.surface))
55
+ return 'tool';
56
+ return now.getTime() - Date.parse(beat.at) <= PANEL_HEARTBEAT_TTL_MS ? 'panel' : 'tool';
57
+ }
58
+ catch {
59
+ return 'tool';
60
+ }
61
+ }
62
+ /**
63
+ * Is a panel question already waiting for this session? The hooks go quiet when
64
+ * one is: it is the outstanding question. Read-only, and false for a database
65
+ * that has no `panel_questions` yet (a hook never migrates).
66
+ */
67
+ export function hasOpenPanelQuestion(db, sessionId) {
68
+ try {
69
+ return (db
70
+ .prepare(`SELECT 1 FROM panel_questions
71
+ WHERE session_id = ? AND phase IN ${OPEN_PHASES} AND created_at >= datetime('now', ?) LIMIT 1`)
72
+ .get(sessionId, `-${PANEL_EXPIRY_HOURS} hours`) !== undefined);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * The explicit round a learner asked for (a topic quiz, `max` questions): the
80
+ * slugs still to ask, in order. The planner saves it when it plans a round for
81
+ * the panel, `present_question` takes each question off it, and Next resumes it,
82
+ * so the round keeps its topic and its length across the questions and the last
83
+ * one is known to be last. Stored in `meta`, so it needs no migration.
84
+ */
85
+ const roundKey = (sessionId) => `panel_round:${sessionId}`;
86
+ export function saveRound(db, sessionId, queue, now = new Date()) {
87
+ db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
88
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(roundKey(sessionId), JSON.stringify({ at: now.toISOString(), queue }));
89
+ }
90
+ export function clearRound(db, sessionId) {
91
+ db.prepare('DELETE FROM meta WHERE key = ?').run(roundKey(sessionId));
92
+ }
93
+ /** The slugs still to ask, or null when no round is running (or it is a day old). */
94
+ export function loadRound(db, sessionId, now = new Date()) {
95
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(roundKey(sessionId));
96
+ if (!row)
97
+ return null;
98
+ const round = JSON.parse(row.value);
99
+ return now.getTime() - Date.parse(round.at) <= PANEL_EXPIRY_HOURS * 3600_000 ? round.queue : null;
100
+ }
101
+ /**
102
+ * Takes `slug` off the round as it is presented. Returns whether more remain,
103
+ * or null when `slug` is not part of a running round (the caller's own `more`
104
+ * then stands).
105
+ */
106
+ export function advanceRound(db, sessionId, slug) {
107
+ const queue = loadRound(db, sessionId);
108
+ if (!queue || !queue.includes(slug))
109
+ return null;
110
+ const rest = queue.filter((s) => s !== slug);
111
+ if (rest.length > 0)
112
+ saveRound(db, sessionId, rest);
113
+ else
114
+ clearRound(db, sessionId);
115
+ return rest.length > 0;
116
+ }
117
+ //# sourceMappingURL=panel-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel-state.js","sourceRoot":"","sources":["../src/panel-state.ts"],"names":[],"mappings":"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"}
package/dist/panel.js ADDED
@@ -0,0 +1,241 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { loadConfig } from './config.js';
3
+ import { answerPosition, MCQ_OPTION_COUNT } from './mcq.js';
4
+ import { normalizeSlug } from './slug.js';
5
+ import { resolveSessionId } from './session.js';
6
+ import { stripAskHeader } from './ask.js';
7
+ import { conceptBySlug, hasAskedQuestion, projectKey, recentQuestions, ASKED_HISTORY, PASSING_GRADE } from './store.js';
8
+ import { recordAttemptCore } from './tools/record_attempt.js';
9
+ import { OPEN_PHASES, PANEL_EXPIRY_HOURS, advanceRound, panelPresentation, recordHeartbeat } from './panel-state.js';
10
+ /** Open rows past their expiry become `expired`. Called first by every entry point. */
11
+ function expireStale(db) {
12
+ db.prepare(`UPDATE panel_questions SET phase = 'expired', updated_at = datetime('now')
13
+ WHERE phase IN ${OPEN_PHASES} AND created_at < datetime('now', ?)`).run(`-${PANEL_EXPIRY_HOURS} hours`);
14
+ }
15
+ function openRow(db, sessionId, repo) {
16
+ return db
17
+ .prepare(`SELECT * FROM panel_questions WHERE session_id = ? AND repo = ? AND phase IN ${OPEN_PHASES}`)
18
+ .get(sessionId, repo);
19
+ }
20
+ /**
21
+ * Stores a question the model has written and returns immediately. Placement of
22
+ * the right option is enforced here, not trusted: the planner computes it, and a
23
+ * model that ignores it would put the answer in the same slot every time.
24
+ */
25
+ export function presentQuestion(db, args) {
26
+ const { config, repoRoot } = loadConfig(args.cwd);
27
+ const sessionId = resolveSessionId(db, args.session_id, args.cwd);
28
+ const repo = projectKey(repoRoot);
29
+ // Off is inert.
30
+ if (!config.quiz.panel) {
31
+ return { error: 'panel_disabled', detail: 'The side panel is off. Ask with AskUserQuestion, as the plan says.' };
32
+ }
33
+ expireStale(db);
34
+ const slug = normalizeSlug(args.slug);
35
+ const concept = conceptBySlug(db, slug);
36
+ if (!concept) {
37
+ return {
38
+ error: 'unknown_concept',
39
+ slug,
40
+ detail: 'No concept with that slug. Call upsert_concepts or log_session_concepts first.',
41
+ };
42
+ }
43
+ const open = openRow(db, sessionId, repo);
44
+ if (open) {
45
+ return {
46
+ error: 'question_open',
47
+ question_id: open.id,
48
+ detail: 'A question is already waiting in the panel for this session. Do not present another; carry on with the task.',
49
+ };
50
+ }
51
+ // A mod that is not reporting in is no place to leave a question: it would sit
52
+ // unseen and block this session's next one for a day. The learner gets the
53
+ // question card instead, which is what `presentation` in the plan said. After
54
+ // the open-row check, so a question already waiting is named as such.
55
+ if (panelPresentation(db, config, sessionId) !== 'panel') {
56
+ return { error: 'panel_unavailable', detail: 'No side panel is showing questions in this session. Ask with AskUserQuestion instead.' };
57
+ }
58
+ const labels = args.options.map((o) => o.label.trim().toLowerCase());
59
+ // A letter or a number is a marker, not an option: the pane draws its own
60
+ // numbering, and the text the learner must read belongs in the label.
61
+ const markers = args.options.some((o) => /^\(?[a-z0-9][.):]?$/i.test(o.label.trim()));
62
+ const correctAt = args.options.flatMap((o, i) => (o.correct ? [i] : []));
63
+ const badGrade = args.options.some((o) => (o.correct ? o.grade !== 4 : o.grade !== 1 && o.grade !== 2));
64
+ if (args.options.length !== MCQ_OPTION_COUNT ||
65
+ new Set(labels).size !== labels.length ||
66
+ markers ||
67
+ correctAt.length !== 1 ||
68
+ badGrade) {
69
+ return {
70
+ error: 'invalid_options',
71
+ detail: `Give exactly ${MCQ_OPTION_COUNT} options with different labels, each label the option's own words (a complete claim, never a letter or number), exactly one with correct: true and grade 4; every other option grade 2 (right shape, wrong in the way that matters) or 1 (built on a misconception). Nothing was stored.`,
72
+ };
73
+ }
74
+ const expected = answerPosition(concept.slug, recentQuestions(db, concept.id, ASKED_HISTORY).length);
75
+ if (correctAt[0] + 1 !== expected) {
76
+ return {
77
+ error: 'wrong_answer_position',
78
+ expected,
79
+ detail: `Put the correct option in slot ${expected} (1-${MCQ_OPTION_COUNT}), as the plan's answer_position says. Nothing was stored.`,
80
+ };
81
+ }
82
+ const stem = stripAskHeader(args.question);
83
+ const repeat = hasAskedQuestion(db, concept.id, stem);
84
+ const options = args.options.map((o, i) => ({ id: `o${i + 1}`, label: o.label, note: o.description }));
85
+ const key = {
86
+ correct_id: `o${correctAt[0] + 1}`,
87
+ grades: Object.fromEntries(args.options.map((o, i) => [`o${i + 1}`, o.grade])),
88
+ };
89
+ // In a round the queue says whether this is the last question; the model's own
90
+ // `more` only stands for a question outside one.
91
+ const more = advanceRound(db, sessionId, concept.slug) ?? Boolean(args.more);
92
+ const id = randomUUID();
93
+ db.prepare(`INSERT INTO panel_questions (id, session_id, repo, concept_id, tier, stem, options, key, explanation, more)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, sessionId, repo, concept.id, args.difficulty, stem, JSON.stringify(options), JSON.stringify(key), args.explanation, more ? 1 : 0);
95
+ return {
96
+ question_id: id,
97
+ status: 'presented',
98
+ ...(repeat ? { repeat_question: true } : {}),
99
+ next: 'The panel shows it and records the answer. Do not wait, do not ask anything else, and do not call record_attempt; carry on with the task.',
100
+ };
101
+ }
102
+ /**
103
+ * What the mod asks on every lifecycle event: is a question waiting here? The
104
+ * reply never carries the key, the grades or the explanation, so nothing the
105
+ * mod holds can reveal the answer before it is given.
106
+ */
107
+ export function panelSync(db, args) {
108
+ const { config, repoRoot } = loadConfig(args.cwd);
109
+ const repo = projectKey(repoRoot);
110
+ // Off is inert: no heartbeat, nothing for the mod to show. A question still
111
+ // open here is superseded by the card the session falls back to, so it is
112
+ // expired (no attempt written) and turning the panel back on cannot revive it.
113
+ if (!config.quiz.panel) {
114
+ db.prepare(`UPDATE panel_questions SET phase = 'expired', updated_at = datetime('now')
115
+ WHERE session_id = ? AND repo = ? AND phase IN ${OPEN_PHASES}`).run(args.session_id, repo);
116
+ return { disabled: true };
117
+ }
118
+ expireStale(db);
119
+ recordHeartbeat(db, args.session_id, args.host, args.placed?.ok);
120
+ const row = openRow(db, args.session_id, repo);
121
+ if (!row)
122
+ return { none: true };
123
+ // Answered by another route while the panel was off (the sync that would have
124
+ // expired it never ran): the card's attempt supersedes it.
125
+ const superseded = db
126
+ .prepare('SELECT 1 FROM attempts WHERE session_id = ? AND concept_id = ? AND ts > ? LIMIT 1')
127
+ .get(row.session_id, row.concept_id, row.created_at);
128
+ if (superseded) {
129
+ db.prepare("UPDATE panel_questions SET phase = 'expired', updated_at = datetime('now') WHERE id = ?").run(row.id);
130
+ return { none: true };
131
+ }
132
+ // The mod reports whether the host could seat the pane. An invisible question
133
+ // is never counted as shown: it waits as `unplaced` until a later open works.
134
+ const placed = args.placed;
135
+ let phase = row.phase;
136
+ if (placed && placed.question_id === row.id) {
137
+ const next = placed.ok ? (phase === 'unplaced' ? 'pending' : phase) : phase === 'pending' ? 'unplaced' : phase;
138
+ if (next !== phase) {
139
+ db.prepare("UPDATE panel_questions SET phase = ?, updated_at = datetime('now') WHERE id = ?").run(next, row.id);
140
+ phase = next;
141
+ }
142
+ }
143
+ const concept = db.prepare('SELECT name FROM concepts WHERE id = ?').get(row.concept_id);
144
+ return {
145
+ question_id: row.id,
146
+ repo: row.repo,
147
+ stem: row.stem,
148
+ options: JSON.parse(row.options),
149
+ phase,
150
+ concept_name: concept.name,
151
+ tier: row.tier,
152
+ more: row.more === 1,
153
+ };
154
+ }
155
+ /**
156
+ * Grades and records one answer, exactly once. A second call for the same
157
+ * question returns the stored reply and writes nothing.
158
+ */
159
+ export function panelAnswer(db, args) {
160
+ return db.transaction(() => {
161
+ expireStale(db);
162
+ const row = db.prepare('SELECT * FROM panel_questions WHERE id = ?').get(args.question_id);
163
+ if (!row)
164
+ return { error: 'unknown_question' };
165
+ // Another session, another project, or a question that timed out.
166
+ if (row.session_id !== args.session_id ||
167
+ row.repo !== args.repo ||
168
+ projectKey(loadConfig(args.cwd).repoRoot) !== row.repo ||
169
+ row.phase === 'expired') {
170
+ return { error: 'stale_question' };
171
+ }
172
+ if (row.result)
173
+ return JSON.parse(row.result);
174
+ const options = JSON.parse(row.options);
175
+ const key = JSON.parse(row.key);
176
+ const picked = options.find((o) => o.id === args.option_id);
177
+ const typed = (args.text ?? '').trim();
178
+ const correctLabel = options.find((o) => o.id === key.correct_id).label;
179
+ // Typed words with no verdict yet: the first half of a typed answer. The
180
+ // panel's grader needs the right option to judge against, so it is handed
181
+ // over now, once the learner has committed to words, and never before.
182
+ // The question is `grading` meanwhile: still open, so nothing else is asked,
183
+ // and a Retry after an interrupted grading simply asks again.
184
+ if (args.kind === 'text' && args.grade === undefined && args.outcome === undefined) {
185
+ if (!typed)
186
+ return { error: 'invalid_answer', detail: 'Nothing was recorded.' };
187
+ if (row.phase !== 'grading') {
188
+ db.prepare("UPDATE panel_questions SET phase = 'grading', updated_at = datetime('now') WHERE id = ?").run(row.id);
189
+ }
190
+ return { phase: 'grading', correct_label: correctLabel };
191
+ }
192
+ if ((args.kind === 'choice' && !picked) ||
193
+ (args.kind === 'text' &&
194
+ (!typed || !Number.isInteger(args.grade) || args.grade < 0 || args.grade > 5 ||
195
+ (args.outcome !== 'answered' && args.outcome !== 'dont_know') ||
196
+ (args.outcome === 'dont_know' && args.grade !== 0)))) {
197
+ return { error: 'invalid_answer', detail: 'Nothing was recorded.' };
198
+ }
199
+ // The table the handoff fixes: a pick takes the grade the model wrote beside
200
+ // it, typed words take the grader's, a skip is a decline.
201
+ const grade = args.kind === 'choice' ? key.grades[picked.id] : args.kind === 'text' ? args.grade : 0;
202
+ const concept = db.prepare('SELECT slug FROM concepts WHERE id = ?').get(row.concept_id);
203
+ const recorded = recordAttemptCore(db, {
204
+ session_id: row.session_id,
205
+ cwd: args.cwd,
206
+ slug: concept.slug,
207
+ question: row.stem,
208
+ answer: args.kind === 'choice' ? picked.label : args.kind === 'text' ? typed : undefined,
209
+ grade,
210
+ difficulty: row.tier,
211
+ feedback: args.kind === 'choice' ? row.explanation : args.kind === 'text' ? args.feedback : undefined,
212
+ outcome: args.kind === 'skip' ? 'declined' : args.kind === 'text' ? args.outcome : 'answered',
213
+ // Free recall carries no format, so it is not capped at 4.
214
+ format: args.kind === 'text' ? undefined : 'mcq',
215
+ options: options.map((o) => o.label),
216
+ correct: correctLabel,
217
+ option_notes: options.map((o) => o.note),
218
+ });
219
+ if ('error' in recorded)
220
+ return recorded;
221
+ const phase = args.kind === 'skip' ? 'skipped' : 'answered';
222
+ const isRight = args.kind === 'choice' ? picked.id === key.correct_id : args.outcome === 'answered' && grade >= PASSING_GRADE;
223
+ const result = {
224
+ phase,
225
+ attempt_id: recorded.attempt_id,
226
+ ...(args.kind === 'skip'
227
+ ? {}
228
+ : {
229
+ correct: isRight,
230
+ ...(isRight ? {} : { correct_label: correctLabel }),
231
+ explanation: args.kind === 'text' && args.feedback ? args.feedback : row.explanation,
232
+ }),
233
+ ...(recorded.level_up ? { level_up: { from: recorded.level_up.from, to: recorded.level_up.to } } : {}),
234
+ ...(recorded.explain ? { explain: recorded.explain } : {}),
235
+ };
236
+ // Same transaction as the attempt: both land or neither does.
237
+ db.prepare("UPDATE panel_questions SET phase = ?, attempt_id = ?, result = ?, updated_at = datetime('now') WHERE id = ?").run(phase, result.attempt_id, JSON.stringify(result), row.id);
238
+ return result;
239
+ }).immediate();
240
+ }
241
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel.js","sourceRoot":"","sources":["../src/panel.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.51.0",
4
+ "version": "1.52.0",
5
5
  "description": "Learn while your agent works. Turns coding-agent generation time into adaptive, Socratic learning grounded in the code being written.",
6
6
  "author": {
7
7
  "name": "Ajay Kumar"
@@ -13,5 +13,6 @@
13
13
  "tutor",
14
14
  "knowledge-graph",
15
15
  "mentorship"
16
- ]
16
+ ],
17
+ "types": "./hooks/panel/types/index.d.ts"
17
18
  }
@@ -42,7 +42,9 @@ Reply with a letter, or "teach me" if you'd rather I explain it.
42
42
 
43
43
  The plan's `ask_attribution` names a `header` field you do not have. Take the
44
44
  rest of it and put `[Eklavya]` on its own line above the stem — always, on
45
- every host. The chip it describes is what the main session gets instead, and
45
+ every host. If the plan says `presentation: "panel"` it has no `ask_attribution`
46
+ and you have no `present_question` either: ignore it, ask in lettered text as
47
+ here, and `record_attempt` as usual. The chip it describes is what the main session gets instead, and
46
48
  without it your question arrives in the transcript unsigned.
47
49
 
48
50
  The answer is at C there because the plan said `answer_position: 3`, and
@@ -27,6 +27,24 @@ The MCP matcher accepts both standalone and plugin-scoped names. Do not narrow
27
27
  it to one prefix. Keep the work-tool regex anchored. Hook timeouts are 10 seconds,
28
28
  except Stop at 15 seconds; consult the manifest before changing them.
29
29
 
30
+ ## The quiz panel is a mod, not a command hook
31
+
32
+ `hooks/panel/` is a Claude Code mod: function hooks declared under `modules` in
33
+ `hooks.json` beside the command hooks, in a runtime with no Node and no
34
+ filesystem. Fail-open applies to it exactly as to a command hook: a handler that
35
+ throws is skipped and the question stays in the database (`panel_questions`) for
36
+ the next sync. The rules it is validated against: static relative imports only,
37
+ every `$` call inline or in a function declared at the top of the file, literal
38
+ event names, render only its own pane, and never hold the answer key (the server
39
+ grades; the mod draws and relays). It is opt-in: with `quiz.panel` off, `panel_sync`
40
+ returns `{disabled: true}` and the mod opens and draws nothing. `presentation` in
41
+ `get_session_quiz_plan` is the one source for which tool asks; the checkpoint,
42
+ the Stop sweep and the delegation lines (`delegation-lib.ts`) read it, and an
43
+ open panel question silences the checkpoint and the Stop sweep. Read
44
+ `docs/verified-schemas.md` (mods section) before changing anything it records.
45
+ Run `scripts/test-panel-mod.sh`: it stages the mod as its own plugin, validates
46
+ it, runs `claude plugin test`, then validates the distributed plugin.
47
+
30
48
  ## Failures and latency
31
49
 
32
50
  Every hook body runs inside `await run(async (input) => { ... })` and returns 0.
@@ -6,7 +6,10 @@
6
6
  {
7
7
  "type": "command",
8
8
  "command": "node",
9
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "session-start"],
9
+ "args": [
10
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
11
+ "session-start"
12
+ ],
10
13
  "timeout": 10,
11
14
  "statusMessage": "Eklavya: loading learner profile"
12
15
  }
@@ -19,7 +22,10 @@
19
22
  {
20
23
  "type": "command",
21
24
  "command": "node",
22
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "prompt-submit-nudge"],
25
+ "args": [
26
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
27
+ "prompt-submit-nudge"
28
+ ],
23
29
  "timeout": 10
24
30
  }
25
31
  ]
@@ -31,7 +37,10 @@
31
37
  {
32
38
  "type": "command",
33
39
  "command": "node",
34
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "subagent-start"],
40
+ "args": [
41
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
42
+ "subagent-start"
43
+ ],
35
44
  "timeout": 10
36
45
  }
37
46
  ]
@@ -44,7 +53,10 @@
44
53
  {
45
54
  "type": "command",
46
55
  "command": "node",
47
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "pre-tool-gate"],
56
+ "args": [
57
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
58
+ "pre-tool-gate"
59
+ ],
48
60
  "timeout": 10
49
61
  }
50
62
  ]
@@ -55,7 +67,10 @@
55
67
  {
56
68
  "type": "command",
57
69
  "command": "node",
58
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "file-context"],
70
+ "args": [
71
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
72
+ "file-context"
73
+ ],
59
74
  "timeout": 10
60
75
  }
61
76
  ]
@@ -67,7 +82,10 @@
67
82
  {
68
83
  "type": "command",
69
84
  "command": "node",
70
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "capture-tool"],
85
+ "args": [
86
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
87
+ "capture-tool"
88
+ ],
71
89
  "timeout": 10
72
90
  }
73
91
  ]
@@ -78,7 +96,10 @@
78
96
  {
79
97
  "type": "command",
80
98
  "command": "node",
81
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "checkpoint-quiz"],
99
+ "args": [
100
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
101
+ "checkpoint-quiz"
102
+ ],
82
103
  "timeout": 10,
83
104
  "statusMessage": "Eklavya: checking if it is time to ask"
84
105
  }
@@ -90,7 +111,10 @@
90
111
  {
91
112
  "type": "command",
92
113
  "command": "node",
93
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "checkpoint-quiz"],
114
+ "args": [
115
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
116
+ "checkpoint-quiz"
117
+ ],
94
118
  "timeout": 10
95
119
  }
96
120
  ]
@@ -101,7 +125,10 @@
101
125
  {
102
126
  "type": "command",
103
127
  "command": "node",
104
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "delegate-nudge"],
128
+ "args": [
129
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
130
+ "delegate-nudge"
131
+ ],
105
132
  "timeout": 10
106
133
  }
107
134
  ]
@@ -113,12 +140,18 @@
113
140
  {
114
141
  "type": "command",
115
142
  "command": "node",
116
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "stop-quiz-check"],
143
+ "args": [
144
+ "${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs",
145
+ "stop-quiz-check"
146
+ ],
117
147
  "timeout": 15,
118
148
  "statusMessage": "Eklavya: checking what you learned"
119
149
  }
120
150
  ]
121
151
  }
122
152
  ]
123
- }
153
+ },
154
+ "modules": [
155
+ "./panel/register.tsx"
156
+ ]
124
157
  }