eklavya 1.7.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 (95) hide show
  1. package/README.md +69 -0
  2. package/dist/ask.js +58 -0
  3. package/dist/ask.js.map +1 -0
  4. package/dist/assets/dashboard.html +407 -0
  5. package/dist/assets/tokens.css +135 -0
  6. package/dist/assets/tutor-skill.md +345 -0
  7. package/dist/cli.js +223 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/concurrency.js +41 -0
  10. package/dist/concurrency.js.map +1 -0
  11. package/dist/config.js +158 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/dashboard.js +214 -0
  14. package/dist/dashboard.js.map +1 -0
  15. package/dist/db.js +26 -0
  16. package/dist/db.js.map +1 -0
  17. package/dist/hooks/checkpoint-quiz.js +152 -0
  18. package/dist/hooks/checkpoint-quiz.js.map +1 -0
  19. package/dist/hooks/lib.js +177 -0
  20. package/dist/hooks/lib.js.map +1 -0
  21. package/dist/hooks/pre-tool-gate.js +53 -0
  22. package/dist/hooks/pre-tool-gate.js.map +1 -0
  23. package/dist/hooks/session-start.js +128 -0
  24. package/dist/hooks/session-start.js.map +1 -0
  25. package/dist/hooks/stop-quiz-check.js +147 -0
  26. package/dist/hooks/stop-quiz-check.js.map +1 -0
  27. package/dist/install.js +313 -0
  28. package/dist/install.js.map +1 -0
  29. package/dist/mcq.js +34 -0
  30. package/dist/mcq.js.map +1 -0
  31. package/dist/migrate.js +45 -0
  32. package/dist/migrate.js.map +1 -0
  33. package/dist/migrations/001_init.sql +71 -0
  34. package/dist/migrations/002_stop_markers.sql +14 -0
  35. package/dist/migrations/003_gate_repo.sql +5 -0
  36. package/dist/migrations/004_attempt_outcome.sql +15 -0
  37. package/dist/migrations/005_session_concept_origin.sql +24 -0
  38. package/dist/migrations/006_attempt_format.sql +31 -0
  39. package/dist/migrations/007_checkpoints.sql +23 -0
  40. package/dist/migrations/008_difficulty_levels.sql +57 -0
  41. package/dist/paths.js +25 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/plugin/.claude-plugin/marketplace.json +14 -0
  44. package/dist/plugin/.claude-plugin/plugin.json +17 -0
  45. package/dist/plugin/.mcp.json +11 -0
  46. package/dist/plugin/agents/tutor.md +63 -0
  47. package/dist/plugin/cli/eklavya-gate +67 -0
  48. package/dist/plugin/hooks/hooks.json +57 -0
  49. package/dist/plugin/hooks/run.mjs +169 -0
  50. package/dist/plugin/scripts/install-git-hook.sh +81 -0
  51. package/dist/plugin/skills/gate/SKILL.md +17 -0
  52. package/dist/plugin/skills/learn/SKILL.md +28 -0
  53. package/dist/plugin/skills/level/SKILL.md +53 -0
  54. package/dist/plugin/skills/mode/SKILL.md +56 -0
  55. package/dist/plugin/skills/progress/SKILL.md +72 -0
  56. package/dist/plugin/skills/quiz/SKILL.md +31 -0
  57. package/dist/plugin/skills/setup/SKILL.md +49 -0
  58. package/dist/plugin/skills/tutor/SKILL.md +345 -0
  59. package/dist/seed/git.json +49 -0
  60. package/dist/seed/node-backend.json +43 -0
  61. package/dist/seed/react.json +46 -0
  62. package/dist/seed/web-auth.json +78 -0
  63. package/dist/seed.js +112 -0
  64. package/dist/seed.js.map +1 -0
  65. package/dist/server.js +52 -0
  66. package/dist/server.js.map +1 -0
  67. package/dist/session.js +24 -0
  68. package/dist/session.js.map +1 -0
  69. package/dist/slug.js +75 -0
  70. package/dist/slug.js.map +1 -0
  71. package/dist/srs.js +206 -0
  72. package/dist/srs.js.map +1 -0
  73. package/dist/store.js +450 -0
  74. package/dist/store.js.map +1 -0
  75. package/dist/tools/config_tools.js +119 -0
  76. package/dist/tools/config_tools.js.map +1 -0
  77. package/dist/tools/get_concept_graph.js +104 -0
  78. package/dist/tools/get_concept_graph.js.map +1 -0
  79. package/dist/tools/get_gate_status.js +20 -0
  80. package/dist/tools/get_gate_status.js.map +1 -0
  81. package/dist/tools/get_learner_profile.js +142 -0
  82. package/dist/tools/get_learner_profile.js.map +1 -0
  83. package/dist/tools/get_session_quiz_plan.js +393 -0
  84. package/dist/tools/get_session_quiz_plan.js.map +1 -0
  85. package/dist/tools/index.js +45 -0
  86. package/dist/tools/index.js.map +1 -0
  87. package/dist/tools/log_session_concepts.js +103 -0
  88. package/dist/tools/log_session_concepts.js.map +1 -0
  89. package/dist/tools/record_attempt.js +158 -0
  90. package/dist/tools/record_attempt.js.map +1 -0
  91. package/dist/tools/types.js +4 -0
  92. package/dist/tools/types.js.map +1 -0
  93. package/dist/tools/upsert_concepts.js +89 -0
  94. package/dist/tools/upsert_concepts.js.map +1 -0
  95. package/package.json +66 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `eklavya dashboard` — the learning state as a local web page.
3
+ *
4
+ * The terminal report has to fit in twenty lines, so it answers "what now?".
5
+ * This answers "am I getting better?", which needs history, per-project
6
+ * comparison and the full concept list — none of which fit in a paragraph.
7
+ *
8
+ * Deliberately a static page plus one JSON endpoint: no framework, no build
9
+ * step, same rule the landing page follows. It binds to loopback only, which
10
+ * is the whole security model — the data never leaves the machine, and that
11
+ * is a promise the landing page makes on Eklavya's behalf.
12
+ */
13
+ import http from 'node:http';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { decayedScore, isDue, isKnown } from './srs.js';
18
+ import { GLOBAL_PROJECT } from './store.js';
19
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
20
+ /**
21
+ * High, unassigned, and deliberately boring to collide with.
22
+ *
23
+ * The low 5000s are where every dev server lands — Vite alone walks 5173, 5174,
24
+ * 5175 upward as it finds ports taken — so a default down there is a default
25
+ * you have to override. This sits above the registered services in /etc/services
26
+ * and below the 49152+ ephemeral range the OS hands out for outbound sockets,
27
+ * so neither end can claim it first. (1729 is the Hardy–Ramanujan number, which
28
+ * is as good a reason as any to remember it.)
29
+ */
30
+ const DEFAULT_PORT = 41729;
31
+ /** Enough history to see a trend, short enough to stay one screen wide. */
32
+ const TIMELINE_DAYS = 30;
33
+ export function dashboardState(db) {
34
+ const now = new Date();
35
+ // One row per calendar day, split three ways. `grade >= 3` is the same pass
36
+ // line the level ladder uses, so the chart and the promotion agree.
37
+ const timeline = db
38
+ .prepare(`SELECT date(ts) AS day,
39
+ NULLIF(trim(COALESCE(repo, '')), '') AS repo,
40
+ sum(CASE WHEN grade >= 3 THEN 1 ELSE 0 END) AS passed,
41
+ sum(CASE WHEN grade < 3 AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
42
+ sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
43
+ FROM attempts
44
+ WHERE ts >= date('now', ?)
45
+ GROUP BY day, repo
46
+ ORDER BY day`)
47
+ .all(`-${TIMELINE_DAYS} days`);
48
+ // A null repo is the pre-migration-003 bucket. It is kept, not dropped:
49
+ // hiding those rows would silently shrink every total on the page.
50
+ const projects = db
51
+ .prepare(`SELECT NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
52
+ count(*) AS answers,
53
+ sum(CASE WHEN a.grade >= 3 THEN 1 ELSE 0 END) AS passed,
54
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
55
+ count(DISTINCT a.concept_id) AS concepts,
56
+ max(a.ts) AS last_active
57
+ FROM attempts a
58
+ GROUP BY repo
59
+ ORDER BY last_active DESC`)
60
+ .all();
61
+ const levels = new Map(db.prepare('SELECT repo, level, promoted_at FROM project_levels').all().map((r) => [r.repo, r]));
62
+ const conceptRows = db
63
+ .prepare(`SELECT c.slug, c.name, c.domain, c.tier,
64
+ m.score, m.reps, m.next_review, m.last_seen,
65
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id) AS attempts,
66
+ (SELECT a.grade FROM attempts a WHERE a.concept_id = c.id ORDER BY a.id DESC LIMIT 1) AS last_grade,
67
+ (SELECT sc.context FROM session_concepts sc
68
+ WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
69
+ ORDER BY sc.ts DESC LIMIT 1) AS last_context,
70
+ (SELECT a.repo FROM attempts a
71
+ WHERE a.concept_id = c.id AND a.repo IS NOT NULL AND trim(a.repo) <> ''
72
+ ORDER BY a.id DESC LIMIT 1) AS last_repo
73
+ FROM concepts c
74
+ LEFT JOIN mastery m ON m.concept_id = c.id`)
75
+ .all();
76
+ const domains = new Map();
77
+ const concepts = [];
78
+ for (const row of conceptRows) {
79
+ const bucket = domains.get(row.domain) ?? { domain: row.domain, mastered: 0, learning: 0, unseen: 0 };
80
+ domains.set(row.domain, bucket);
81
+ if (row.attempts === 0) {
82
+ bucket.unseen += 1;
83
+ continue;
84
+ }
85
+ // Read-time decay only, exactly as the profile computes it — two surfaces
86
+ // disagreeing about the same score is worse than either being wrong.
87
+ const score = decayedScore(row.score ?? 0, row.next_review, now);
88
+ const mastered = isKnown({ score, reps: row.reps ?? 0 });
89
+ if (mastered)
90
+ bucket.mastered += 1;
91
+ else
92
+ bucket.learning += 1;
93
+ concepts.push({
94
+ slug: row.slug,
95
+ name: row.name,
96
+ domain: row.domain,
97
+ tier: row.tier,
98
+ score: Number(score.toFixed(2)),
99
+ reps: row.reps ?? 0,
100
+ attempts: row.attempts,
101
+ last_grade: row.last_grade,
102
+ mastered,
103
+ due: isDue(row.next_review, now),
104
+ next_review: row.next_review,
105
+ last_seen: row.last_seen,
106
+ context: row.last_context,
107
+ repo: row.last_repo,
108
+ });
109
+ }
110
+ concepts.sort((a, b) => a.score - b.score);
111
+ const skipped = db
112
+ .prepare(`SELECT c.slug AS slug, c.name AS name, c.domain AS domain,
113
+ a.outcome AS outcome, a.repo AS repo, a.ts AS ts, a.question AS question
114
+ FROM attempts a JOIN concepts c ON c.id = a.concept_id
115
+ WHERE a.outcome IN ('declined','dont_know')
116
+ ORDER BY a.ts DESC`)
117
+ .all();
118
+ const recent = db
119
+ .prepare(`SELECT c.slug AS slug, c.domain AS domain, sc.context AS context, sc.ts AS ts, g.repo AS repo
120
+ FROM session_concepts sc
121
+ JOIN concepts c ON c.id = sc.concept_id
122
+ LEFT JOIN gates g ON g.session_id = sc.session_id
123
+ WHERE sc.context IS NOT NULL AND trim(sc.context) <> ''
124
+ ORDER BY sc.ts DESC
125
+ LIMIT 40`)
126
+ .all();
127
+ const totals = timeline.reduce((acc, d) => ({
128
+ passed: acc.passed + d.passed,
129
+ missed: acc.missed + d.missed,
130
+ skipped: acc.skipped + d.skipped,
131
+ }), { passed: 0, missed: 0, skipped: 0 });
132
+ const allTime = db
133
+ .prepare(`SELECT count(*) AS answers,
134
+ sum(CASE WHEN grade >= 3 THEN 1 ELSE 0 END) AS passed,
135
+ sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
136
+ FROM attempts`)
137
+ .get();
138
+ return {
139
+ generated_at: now.toISOString(),
140
+ timeline_days: TIMELINE_DAYS,
141
+ totals: {
142
+ answers: allTime.answers,
143
+ passed: allTime.passed ?? 0,
144
+ skipped: allTime.skipped ?? 0,
145
+ mastered: concepts.filter((c) => c.mastered).length,
146
+ due: concepts.filter((c) => c.due).length,
147
+ touched: concepts.length,
148
+ catalogue: conceptRows.length,
149
+ window: totals,
150
+ },
151
+ timeline,
152
+ projects: projects.map((p) => ({
153
+ ...p,
154
+ level: levels.get(p.repo ?? GLOBAL_PROJECT)?.level ?? 'easy',
155
+ promoted_at: levels.get(p.repo ?? GLOBAL_PROJECT)?.promoted_at ?? null,
156
+ })),
157
+ domains: [...domains.values()].sort((a, b) => b.mastered + b.learning - (a.mastered + a.learning)),
158
+ concepts,
159
+ skipped,
160
+ recent,
161
+ };
162
+ }
163
+ function send(res, status, type, body) {
164
+ res.writeHead(status, {
165
+ 'content-type': type,
166
+ // A dashboard read from a stale cache is a dashboard that lies about
167
+ // progress made ten seconds ago, which is the one thing it is for.
168
+ 'cache-control': 'no-store',
169
+ });
170
+ res.end(body);
171
+ }
172
+ /**
173
+ * Starts the server and resolves with the URL it actually bound to. The
174
+ * requested port may be taken by a second dashboard or an unrelated dev
175
+ * server; falling back to an ephemeral port beats failing with EADDRINUSE
176
+ * when the caller does not care which port it gets.
177
+ */
178
+ export function startDashboard(db, opts = {}) {
179
+ const host = opts.host ?? '127.0.0.1';
180
+ const wanted = opts.port ?? DEFAULT_PORT;
181
+ const assets = path.join(moduleDir, 'assets');
182
+ const server = http.createServer((req, res) => {
183
+ const url = new URL(req.url ?? '/', `http://${host}`);
184
+ try {
185
+ if (url.pathname === '/api/state') {
186
+ return send(res, 200, 'application/json', JSON.stringify(dashboardState(db)));
187
+ }
188
+ if (url.pathname === '/tokens.css') {
189
+ return send(res, 200, 'text/css', fs.readFileSync(path.join(assets, 'tokens.css')));
190
+ }
191
+ if (url.pathname === '/' || url.pathname === '/index.html') {
192
+ return send(res, 200, 'text/html; charset=utf-8', fs.readFileSync(path.join(assets, 'dashboard.html')));
193
+ }
194
+ return send(res, 404, 'text/plain', 'not found');
195
+ }
196
+ catch (err) {
197
+ return send(res, 500, 'text/plain', err instanceof Error ? err.message : 'error');
198
+ }
199
+ });
200
+ return new Promise((resolve, reject) => {
201
+ server.once('error', (err) => {
202
+ if (err.code !== 'EADDRINUSE' || opts.port !== undefined)
203
+ return reject(err);
204
+ server.listen(0, host);
205
+ });
206
+ server.on('listening', () => {
207
+ const addr = server.address();
208
+ const port = typeof addr === 'object' && addr ? addr.port : wanted;
209
+ resolve({ url: `http://${host}:${port}`, close: () => server.close() });
210
+ });
211
+ server.listen(wanted, host);
212
+ });
213
+ }
214
+ //# sourceMappingURL=dashboard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.js","sourceRoot":"","sources":["../src/dashboard.ts"],"names":[],"mappings":"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"}
package/dist/db.js ADDED
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { dbPath } from './paths.js';
5
+ import { runMigrations } from './migrate.js';
6
+ import { seedIfNeeded } from './seed.js';
7
+ /**
8
+ * Opens (creating if needed) the knowledge DB with the pragmas the design
9
+ * depends on: WAL so several Claude Code sessions and the git hook can share
10
+ * the file (PRD §8 "Concurrency"), and foreign keys so the graph stays honest.
11
+ */
12
+ export function openDb(file = dbPath()) {
13
+ if (file !== ':memory:') {
14
+ fs.mkdirSync(path.dirname(file), { recursive: true });
15
+ }
16
+ const db = new Database(file);
17
+ db.pragma('journal_mode = WAL');
18
+ db.pragma('foreign_keys = ON');
19
+ // Concurrent writers (two sessions, or a session plus the pre-commit hook)
20
+ // should wait briefly rather than fail with SQLITE_BUSY.
21
+ db.pragma('busy_timeout = 5000');
22
+ runMigrations(db);
23
+ seedIfNeeded(db);
24
+ return db;
25
+ }
26
+ //# sourceMappingURL=db.js.map
package/dist/db.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAIzC;;;;GAIG;AACH,MAAM,UAAU,MAAM,CAAC,OAAe,MAAM,EAAE;IAC5C,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC9B,EAAE,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAChC,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAC/B,2EAA2E;IAC3E,yDAAyD;IACzD,EAAE,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAAC;IAEjC,aAAa,CAAC,EAAE,CAAC,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,CAAC;IAEjB,OAAO,EAAE,CAAC;AACZ,CAAC"}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * PostToolUse(log_session_concepts): ask ONE question, here, now, mid-task.
3
+ *
4
+ * This is the hook that makes the pitch literal. Everything else in Eklavya
5
+ * quizzes at Stop, which means the developer is asked four questions at the exact
6
+ * moment they wanted to be finished -- learning bolted onto the end of the work
7
+ * rather than happening during it. The seam this fires on is the best one
8
+ * available: the model has just told the server "this task exercises
9
+ * jwt-verification, and here is the line where", so the code that teaches the
10
+ * concept is still on screen and still in context. A question asked there costs
11
+ * seconds. The same question asked twenty minutes later costs a re-read.
12
+ *
13
+ * THE BURST GUARD IS THE POINT OF THIS FILE, exactly as the loop guard is the
14
+ * point of stop-quiz-check.ts -- but it guards a different failure. That one
15
+ * stops an end-of-turn loop; this one stops a mid-turn pile-up. The model logs
16
+ * concepts in batches of 3-8 and may call the tool several times in one task, so
17
+ * the naive version asks eight questions in a row and gets uninstalled. Three
18
+ * rules keep it to a trickle:
19
+ *
20
+ * 1. one question per checkpoint, never a batch (max: 1, and we say so);
21
+ * 2. min_minutes_between_checkpoints since the last one, wall clock;
22
+ * 3. the session budget is max_questions_per_task, shared with the Stop hook.
23
+ *
24
+ * Rule 3 is why this does not add questions, it moves them. Every answer here is
25
+ * an `attempts` row, and stop-quiz-check.ts subtracts those from its own budget.
26
+ * Answer the lot while the agent works and the Stop hook says nothing at all.
27
+ *
28
+ * Failure is always silent: exit 0, no output. A missed question is nothing; a
29
+ * hook that errors on every tool call is a plugin nobody keeps.
30
+ */
31
+ import { run, openExisting, config, cwdOf, sessionId, minutesSince, framingFor } from './lib.js';
32
+ await run(async (input) => {
33
+ // Fast path. `agent_id` is present only inside a subagent, and a subagent
34
+ // cannot ask the developer anything -- AskUserQuestion is not in its toolset,
35
+ // and the human is not watching that transcript. Checkpointing there would
36
+ // burn a question from the budget on a prompt nobody ever sees.
37
+ if (input.agent_id)
38
+ return 0;
39
+ const db = openExisting();
40
+ if (!db)
41
+ return 0;
42
+ const cwd = cwdOf(input);
43
+ const { mode, cadence, focus, focus_topic, max_questions_per_task, min_minutes_between_checkpoints } = config(cwd).config;
44
+ if (mode === 'off')
45
+ return 0;
46
+ // The whole feature behind one switch. `end` is the pre-1.4 behaviour: silence
47
+ // until Stop.
48
+ if (cadence !== 'interleaved')
49
+ return 0;
50
+ const sid = sessionId(input, db);
51
+ if (!sid)
52
+ return 0;
53
+ // One query for every number this decision needs.
54
+ //
55
+ // The candidate count and the concept line below share their WHERE clause with
56
+ // stop-quiz-check.ts, and must: a concept this hook considers unaskable is one
57
+ // the Stop hook has to catch, and the two disagreeing is how a learner gets
58
+ // asked about something twice or never.
59
+ const stats = db
60
+ .prepare(`SELECT
61
+ (SELECT count(*) FROM session_concepts sc
62
+ JOIN concepts c ON c.id = sc.concept_id
63
+ LEFT JOIN mastery m ON m.concept_id = c.id
64
+ WHERE sc.session_id = @sid
65
+ AND COALESCE(sc.origin,'work') = 'work'
66
+ AND NOT (COALESCE(m.score,0) >= 0.7 AND COALESCE(m.reps,0) >= 2)
67
+ AND sc.concept_id NOT IN
68
+ (SELECT concept_id FROM attempts WHERE session_id = @sid)) AS candidates,
69
+ -- Spent budget. Every attempt counts, wherever it was asked: a question
70
+ -- the Stop hook already got answered is one this hook must not re-spend.
71
+ (SELECT count(*) FROM attempts WHERE session_id = @sid) AS spent,
72
+ -- Both clocks, for the same reason stop-quiz-check.ts checks both: a
73
+ -- checkpoint stamped without a question ever being asked (the model
74
+ -- ignored us) should still pace the next one, and an answer given
75
+ -- without a checkpoint (the Stop hook ran, or the developer asked for a
76
+ -- quiz) should too.
77
+ (SELECT last_checkpoint_at FROM checkpoints WHERE session_id = @sid) AS last_checkpoint,
78
+ (SELECT ts FROM attempts WHERE session_id = @sid ORDER BY id DESC LIMIT 1) AS last_answer`)
79
+ .get({ sid });
80
+ if (!stats)
81
+ return 0;
82
+ // Nothing unmastered and unasked. Concepts already attempted this session are
83
+ // excluded above -- they have had their turn.
84
+ if (stats.candidates <= 0)
85
+ return 0;
86
+ // --- the burst guard -------------------------------------------------------
87
+ if (stats.spent >= max_questions_per_task)
88
+ return 0;
89
+ if (minutesSince(stats.last_checkpoint) < min_minutes_between_checkpoints)
90
+ return 0;
91
+ if (minutesSince(stats.last_answer) < min_minutes_between_checkpoints)
92
+ return 0;
93
+ // ---------------------------------------------------------------------------
94
+ // The concept most recently logged, not the oldest. The Stop hook orders ASC
95
+ // because it is sweeping up a whole session; this hook is asking about the code
96
+ // that was just written, and the last row is the one the call that triggered us
97
+ // put there.
98
+ const row = db
99
+ .prepare(`SELECT c.slug || COALESCE(' (' || sc.context || ')', '') AS concept
100
+ FROM session_concepts sc
101
+ JOIN concepts c ON c.id = sc.concept_id
102
+ LEFT JOIN mastery m ON m.concept_id = c.id
103
+ WHERE sc.session_id = @sid
104
+ AND COALESCE(sc.origin,'work') = 'work'
105
+ AND NOT (COALESCE(m.score,0) >= 0.7 AND COALESCE(m.reps,0) >= 2)
106
+ AND sc.concept_id NOT IN
107
+ (SELECT concept_id FROM attempts WHERE session_id = @sid)
108
+ ORDER BY sc.ts DESC, sc.rowid DESC
109
+ LIMIT 1`)
110
+ .get({ sid });
111
+ if (!row?.concept)
112
+ return 0;
113
+ // Stamp BEFORE emitting. If anything below fails the worst case is a missed
114
+ // question; stamping after would let a crash between the two re-fire on the very
115
+ // next tool call, which is the burst this file exists to prevent.
116
+ db.prepare(`INSERT INTO checkpoints (session_id, last_checkpoint_at, count)
117
+ VALUES (@sid, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 1)
118
+ ON CONFLICT(session_id) DO UPDATE SET
119
+ last_checkpoint_at = excluded.last_checkpoint_at,
120
+ count = checkpoints.count + 1`).run({ sid });
121
+ const remaining = max_questions_per_task - stats.spent;
122
+ const framing = framingFor(focus, focus_topic, 'checkpoint');
123
+ const context = `[Eklavya checkpoint] You just logged a concept. Before writing another line, ask the developer ONE question about it -- this is the whole point of the tool: they learn while you work, not in a pile at the end.
124
+
125
+ Concept: ${row.concept}
126
+
127
+ Do exactly this, then get straight back to the task:
128
+ 1. get_session_quiz_plan with max: 1 and ignore_cooldown: true (the pacing is already decided -- this hook is the cooldown).
129
+ 2. Ask that ONE question with AskUserQuestion: four options, one correct, three plausible, and put the correct one in the slot answer_position names. Header "Eklavya", so it is clear who is asking.
130
+ End the question text with a blank line and then the plan item's ask_footer, verbatim -- it is the only thing on screen naming the mode, focus, level and tier that chose this question. Show it whenever the plan carries one.
131
+ 3. Grade it with record_attempt: format "mcq", the labels in "options", the stem alone in "question".
132
+ 4. Resume the task exactly where you left off. Do not summarise, do not re-plan, do not ask a second question.
133
+
134
+ ONE question. Not two, not the whole plan. ${remaining} left in this session's budget, and the Stop hook spends whatever you do not.
135
+ ${framing}
136
+ If they pick Other or say skip, record it as grade 0, teach the answer in two lines, and carry on. Do not ask again.`;
137
+ // exit 0 + JSON, not exit 2 + stderr. The Stop hook uses exit 2 because it has
138
+ // to *prevent* something; this hook has nothing to prevent, and exit 2 renders
139
+ // to the developer as a hook warning -- an error face on a feature that is
140
+ // working. So: additionalContext carries the instruction to the model,
141
+ // systemMessage tells the human why their agent just stopped to ask them
142
+ // something.
143
+ process.stdout.write(`${JSON.stringify({
144
+ hookSpecificOutput: {
145
+ hookEventName: 'PostToolUse',
146
+ additionalContext: context,
147
+ systemMessage: 'Eklavya: quick question on what you just built',
148
+ },
149
+ })}\n`);
150
+ return 0;
151
+ });
152
+ //# sourceMappingURL=checkpoint-quiz.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkpoint-quiz.js","sourceRoot":"","sources":["../../src/hooks/checkpoint-quiz.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Shared helpers for the Eklavya hooks.
3
+ *
4
+ * These used to be `hooks/lib.sh`, and the port was not cosmetic. Hook commands
5
+ * run through `sh -c` on macOS and Linux but through Git Bash — or PowerShell,
6
+ * or WSL's bash, depending on what is installed — on Windows, where `.sh` hooks
7
+ * are a documented minefield (claude-code#18610, #21847, #23556, #73971). The
8
+ * hooks reference names the way out: `node` plus a script path is the one form
9
+ * that works everywhere, because `node.exe` is a real executable.
10
+ *
11
+ * Porting also deletes the dependency list. The shell version needed `jq`,
12
+ * the `sqlite3` CLI and a POSIX shell on PATH before it could do anything;
13
+ * this version needs the Node that Eklavya already requires.
14
+ *
15
+ * The hard rule survives the port unchanged (PRD §9.1): a hook must never break
16
+ * a session. `run()` is the only entry point, and it swallows everything.
17
+ */
18
+ import fs from 'node:fs';
19
+ import Database from 'better-sqlite3';
20
+ import { dbPath } from '../paths.js';
21
+ import { loadConfig } from '../config.js';
22
+ /**
23
+ * Reads stdin to EOF. Hooks are always given JSON, but "always" is doing a lot
24
+ * of work on a critical path — a hook invoked by hand, or by a harness version
25
+ * that changes its mind, gets an empty object rather than an exception.
26
+ */
27
+ async function readStdin() {
28
+ if (process.stdin.isTTY)
29
+ return '';
30
+ const chunks = [];
31
+ for await (const chunk of process.stdin)
32
+ chunks.push(chunk);
33
+ return Buffer.concat(chunks).toString('utf8');
34
+ }
35
+ export async function readInput() {
36
+ try {
37
+ const raw = await readStdin();
38
+ if (!raw.trim())
39
+ return {};
40
+ const parsed = JSON.parse(raw);
41
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
42
+ }
43
+ catch {
44
+ return {};
45
+ }
46
+ }
47
+ /**
48
+ * Opens the knowledge DB read-write, WITHOUT migrating or seeding it.
49
+ *
50
+ * `openDb()` is the server's entry point and does both, which is right for a
51
+ * server and wrong here: a hook is not the thing that should be migrating a
52
+ * schema, and four hooks racing a migration on session start is a corruption
53
+ * story rather than a feature. A hook that finds no database has nothing to say,
54
+ * which is the same answer `eklavya_have_deps` gave.
55
+ */
56
+ export function openExisting() {
57
+ const file = dbPath();
58
+ if (!fs.existsSync(file))
59
+ return null;
60
+ try {
61
+ const db = new Database(file);
62
+ db.pragma('busy_timeout = 2000');
63
+ db.pragma('foreign_keys = ON');
64
+ return db;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export function config(cwd) {
71
+ return loadConfig(cwd);
72
+ }
73
+ /** The cwd the hook should reason about: what the harness said, else ours. */
74
+ export function cwdOf(input) {
75
+ return input.cwd && input.cwd.length > 0 ? input.cwd : process.cwd();
76
+ }
77
+ /**
78
+ * The session id every hook and MCP tool must agree on.
79
+ *
80
+ * `EKLAVYA_SESSION_ID` wins when set, so two panes sharing one task (see
81
+ * docs/parallel-tutoring.md) agree on an id even though Claude Code gives each
82
+ * pane its own. Unset in normal use, where the harness id is authoritative. The
83
+ * `meta` fallback is for hooks the harness did not hand an id to.
84
+ */
85
+ export function sessionId(input, db) {
86
+ const fromEnv = process.env.EKLAVYA_SESSION_ID;
87
+ if (fromEnv)
88
+ return fromEnv;
89
+ if (input.session_id)
90
+ return input.session_id;
91
+ if (!db)
92
+ return null;
93
+ try {
94
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'current_session'").get();
95
+ return row?.value ?? null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Minutes since a timestamp, or a number large enough to never gate. */
102
+ export const NEVER = 999_999;
103
+ /**
104
+ * Minutes since a timestamp written by SQLite.
105
+ *
106
+ * This schema stores two shapes, and the difference is a trap. `strftime(...Z)`
107
+ * is explicitly UTC, but `datetime('now')` — the default on `session_concepts.ts`
108
+ * and `attempts.ts` — produces "2026-09-05 18:04:09": UTC, with nothing saying
109
+ * so. `Date.parse` reads that as LOCAL time, so every cooldown came out wrong by
110
+ * the machine's UTC offset, and west of UTC the elapsed time was negative and no
111
+ * cooldown ever passed. The shell version never had this bug because `julianday`
112
+ * assumes UTC for exactly this format.
113
+ *
114
+ * So: normalise to UTC before parsing, and never return a negative — a clock
115
+ * that has moved backwards should read as "just now", not as "never".
116
+ */
117
+ export function minutesSince(ts) {
118
+ if (!ts)
119
+ return NEVER;
120
+ const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(ts);
121
+ const normalised = hasZone ? ts : `${ts.replace(' ', 'T')}Z`;
122
+ const then = Date.parse(normalised);
123
+ if (Number.isNaN(then))
124
+ return NEVER;
125
+ return Math.max(0, Math.floor((Date.now() - then) / 60_000));
126
+ }
127
+ /** The timestamp format every table in this schema stores. */
128
+ export function nowIso() {
129
+ return new Date().toISOString().replace(/(\.\d{3})Z$/, '$1Z');
130
+ }
131
+ /**
132
+ * The focus framing, stated by the hooks rather than left to the tutor skill.
133
+ *
134
+ * The skill is model-invoked: it competes with every other skill on the machine
135
+ * and may never load. Without this line an ambient session teaches whatever the
136
+ * default is rather than what the developer configured.
137
+ */
138
+ export function framingFor(focus, topic, where) {
139
+ if (focus === 'concept') {
140
+ return where === 'checkpoint'
141
+ ? "Focus is 'concept': open from the code you just wrote, then ask for the general rule -- the answer must be usable on a different codebase."
142
+ : "Focus is 'concept': ask the transferable version. Open from the code just written, then ask for the general rule or the class of problem — the answer must be usable on a different codebase.";
143
+ }
144
+ if (focus === 'learn') {
145
+ if (!topic) {
146
+ return where === 'checkpoint'
147
+ ? "Focus is 'learn' but no focus_topic is set. Skip the question and say so."
148
+ : "Focus is 'learn' but no focus_topic is set. Ask what they want to learn and set it before quizzing.";
149
+ }
150
+ return where === 'checkpoint'
151
+ ? `Focus is 'learn' on "${topic}": ask the next thing in that topic. Use the code you just wrote as the worked example only where it genuinely overlaps.`
152
+ : `Focus is 'learn' on "${topic}": teach that topic in prerequisite order. Where the plan marks bridge_context, this session's code is your worked example; otherwise teach it on its own terms.`;
153
+ }
154
+ return where === 'checkpoint'
155
+ ? "Focus is 'project': ground the question in the code you just wrote -- the file, the line, the decision."
156
+ : "Focus is 'project': ground every question in the diff just written — the file, the line, the decision.";
157
+ }
158
+ /**
159
+ * Runs a hook body and guarantees the session survives it.
160
+ *
161
+ * Every failure path exits 0 with no output — a missed question is nothing, a
162
+ * hook that errors on every tool call is a plugin nobody keeps. The one
163
+ * deliberate exception is the Stop hook, which blocks with exit 2 by returning
164
+ * an explicit code; anything thrown still lands here and still exits 0.
165
+ */
166
+ export async function run(body) {
167
+ let code = 0;
168
+ try {
169
+ const input = await readInput();
170
+ code = (await body(input)) ?? 0;
171
+ }
172
+ catch {
173
+ code = 0;
174
+ }
175
+ process.exit(code);
176
+ }
177
+ //# sourceMappingURL=lib.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lib.js","sourceRoot":"","sources":["../../src/hooks/lib.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * PreToolUse(Bash): hold commits until the session quiz is passed.
3
+ *
4
+ * This runs on EVERY Bash call, so the first thing it does is decide the command
5
+ * is none of its business and get out of the way.
6
+ */
7
+ import { run, openExisting, config, cwdOf, sessionId } from './lib.js';
8
+ /**
9
+ * `git commit` only counts at a command position: after the start of the line or
10
+ * a shell separator. This is what keeps `echo "git commit"` from being blocked,
11
+ * while `npm test && git commit -m x` is still caught. The option group allows a
12
+ * value after a flag, so `git -C . commit` and `git -c user.name=x commit` are
13
+ * caught alongside plain `git commit`.
14
+ */
15
+ const COMMIT = /(^|[;&|])\s*(sudo\s+)?git(\s+-\S+(\s+[^-\s]\S*)?)*\s+commit(\s|$)/;
16
+ await run(async (input) => {
17
+ // Fast path: no string match, no work. PreToolUse fires on every Bash call.
18
+ const command = input.tool_input?.command;
19
+ if (!command || !command.includes('commit'))
20
+ return 0;
21
+ if (!COMMIT.test(command))
22
+ return 0;
23
+ const cwd = cwdOf(input);
24
+ if (config(cwd).config.mode !== 'enforced')
25
+ return 0;
26
+ const db = openExisting();
27
+ if (!db)
28
+ return 0;
29
+ const sid = sessionId(input, db);
30
+ if (!sid)
31
+ return 0;
32
+ const gate = db
33
+ .prepare('SELECT passed, required, answered FROM gates WHERE session_id = ?')
34
+ .get(sid);
35
+ // No gate row means nothing was taught this session, so there is nothing to
36
+ // prove. Blocking here would be unpassable, not strict.
37
+ if (!gate)
38
+ return 0;
39
+ if (gate.passed === 1)
40
+ return 0;
41
+ const reason = `Eklavya gate: this session's quiz has not been passed yet (${gate.answered} of ${gate.required} concepts answered). ` +
42
+ 'Run the quiz first — get_session_quiz_plan, ask one question at a time, grade each answer with record_attempt — ' +
43
+ 'then retry the commit. Nothing else is blocked.';
44
+ process.stdout.write(`${JSON.stringify({
45
+ hookSpecificOutput: {
46
+ hookEventName: 'PreToolUse',
47
+ permissionDecision: 'deny',
48
+ permissionDecisionReason: reason,
49
+ },
50
+ })}\n`);
51
+ return 0;
52
+ });
53
+ //# sourceMappingURL=pre-tool-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pre-tool-gate.js","sourceRoot":"","sources":["../../src/hooks/pre-tool-gate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAEvE;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,mEAAmE,CAAC;AAEnF,MAAM,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;IACxB,4EAA4E;IAC5E,MAAM,OAAO,GAAG,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC;IAC1C,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,CAAC,CAAC;IACtD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,CAAC,CAAC;IAEpC,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;IACzB,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO,CAAC,CAAC;IAErD,MAAM,EAAE,GAAG,YAAY,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,CAAC;IAElB,MAAM,GAAG,GAAG,SAAS,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACjC,IAAI,CAAC,GAAG;QAAE,OAAO,CAAC,CAAC;IAEnB,MAAM,IAAI,GAAG,EAAE;SACZ,OAAO,CAAC,mEAAmE,CAAC;SAC5E,GAAG,CAAC,GAAG,CAAuE,CAAC;IAElF,4EAA4E;IAC5E,wDAAwD;IACxD,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,CAAC;IACpB,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAEhC,MAAM,MAAM,GACV,8DAA8D,IAAI,CAAC,QAAQ,OAAO,IAAI,CAAC,QAAQ,uBAAuB;QACtH,kHAAkH;QAClH,iDAAiD,CAAC;IAEpD,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,GAAG,IAAI,CAAC,SAAS,CAAC;QAChB,kBAAkB,EAAE;YAClB,aAAa,EAAE,YAAY;YAC3B,kBAAkB,EAAE,MAAM;YAC1B,wBAAwB,EAAE,MAAM;SACjC;KACF,CAAC,IAAI,CACP,CAAC;IACF,OAAO,CAAC,CAAC;AACX,CAAC,CAAC,CAAC"}