eklavya 1.25.0 → 1.25.2

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 (82) hide show
  1. package/dist/assets/dashboard.html +1 -1
  2. package/dist/assets/tutor/references/focus-and-level.md +7 -0
  3. package/dist/claude-mem.js +9 -7
  4. package/dist/claude-mem.js.map +1 -1
  5. package/dist/cli-memory.js +786 -0
  6. package/dist/cli-memory.js.map +1 -0
  7. package/dist/cli.js +95 -773
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.js +79 -13
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +36 -14
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/db.js +33 -6
  14. package/dist/db.js.map +1 -1
  15. package/dist/hooks/capture-lib.js +55 -0
  16. package/dist/hooks/capture-lib.js.map +1 -0
  17. package/dist/hooks/capture-tool.js +1 -1
  18. package/dist/hooks/capture-tool.js.map +1 -1
  19. package/dist/hooks/commit-lib.js +255 -0
  20. package/dist/hooks/commit-lib.js.map +1 -0
  21. package/dist/hooks/lib.js +37 -5
  22. package/dist/hooks/lib.js.map +1 -1
  23. package/dist/hooks/memory-lib.js +93 -78
  24. package/dist/hooks/memory-lib.js.map +1 -1
  25. package/dist/hooks/pre-tool-gate.js +5 -10
  26. package/dist/hooks/pre-tool-gate.js.map +1 -1
  27. package/dist/hooks/prompt-submit-nudge.js +26 -1
  28. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  29. package/dist/hooks/session-start.js +13 -4
  30. package/dist/hooks/session-start.js.map +1 -1
  31. package/dist/install.js +280 -67
  32. package/dist/install.js.map +1 -1
  33. package/dist/memory/capture.js +25 -5
  34. package/dist/memory/capture.js.map +1 -1
  35. package/dist/memory/privacy.js +105 -7
  36. package/dist/memory/privacy.js.map +1 -1
  37. package/dist/memory/provider.js +13 -3
  38. package/dist/memory/provider.js.map +1 -1
  39. package/dist/memory/recall.js +18 -6
  40. package/dist/memory/recall.js.map +1 -1
  41. package/dist/memory/spool.js +105 -23
  42. package/dist/memory/spool.js.map +1 -1
  43. package/dist/memory/store.js +32 -2
  44. package/dist/memory/store.js.map +1 -1
  45. package/dist/memory/summarize.js +5 -5
  46. package/dist/memory/summarize.js.map +1 -1
  47. package/dist/memory/worker.js +71 -10
  48. package/dist/memory/worker.js.map +1 -1
  49. package/dist/migrate.js +60 -14
  50. package/dist/migrate.js.map +1 -1
  51. package/dist/paths.js +49 -0
  52. package/dist/paths.js.map +1 -1
  53. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  54. package/dist/plugin/cli/CLAUDE.md +16 -4
  55. package/dist/plugin/hooks/CLAUDE.md +33 -3
  56. package/dist/plugin/hooks/run.mjs +69 -5
  57. package/dist/plugin/scripts/install-git-hook.sh +114 -24
  58. package/dist/plugin/skills/CLAUDE.md +1 -1
  59. package/dist/plugin/skills/setup/SKILL.md +9 -2
  60. package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
  61. package/dist/safe-write.js +146 -0
  62. package/dist/safe-write.js.map +1 -0
  63. package/dist/slug.js +4 -1
  64. package/dist/slug.js.map +1 -1
  65. package/dist/srs.js +33 -1
  66. package/dist/srs.js.map +1 -1
  67. package/dist/store.js +29 -20
  68. package/dist/store.js.map +1 -1
  69. package/dist/tools/config_tools.js +23 -1
  70. package/dist/tools/config_tools.js.map +1 -1
  71. package/dist/tools/get_session_quiz_plan.js +51 -30
  72. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  73. package/dist/tools/log_session_concepts.js +30 -23
  74. package/dist/tools/log_session_concepts.js.map +1 -1
  75. package/dist/tools/record_attempt.js +18 -8
  76. package/dist/tools/record_attempt.js.map +1 -1
  77. package/dist/tools/types.js +29 -0
  78. package/dist/tools/types.js.map +1 -1
  79. package/dist/tools/upsert_concepts.js +12 -10
  80. package/dist/tools/upsert_concepts.js.map +1 -1
  81. package/dist/user-skill/eklavya/SKILL.md +26 -10
  82. package/package.json +1 -1
package/dist/migrate.js CHANGED
@@ -10,34 +10,80 @@ function writeVersion(db, version) {
10
10
  db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
11
11
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(VERSION_KEY, String(version));
12
12
  }
13
+ /**
14
+ * How long a process waits for another one's migration, whatever the caller's
15
+ * own `busy_timeout` says. A migration can be slow on a big database -- 014
16
+ * builds an index over every captured event -- and a session queued behind it
17
+ * that gives up after the usual five seconds crashes instead of starting late.
18
+ */
19
+ export const MIGRATION_BUSY_TIMEOUT_MS = 60_000;
20
+ function hasMeta(db) {
21
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get() !== undefined;
22
+ }
13
23
  /**
14
24
  * Applies numbered SQL migrations in order, recording progress in `meta`.
15
25
  * Idempotent: re-running applies nothing. Returns the filenames applied.
26
+ *
27
+ * Safe when several processes migrate one file at once -- every session and
28
+ * every hook opens the database, so after an upgrade they all do. Each
29
+ * migration runs in a `BEGIN IMMEDIATE` transaction that re-reads the version
30
+ * once it holds the write lock, so a process that lost the race skips what the
31
+ * winner applied instead of re-running it. Reading the version outside the
32
+ * lock and trusting it is what let six processes all try the same
33
+ * `ALTER TABLE ... ADD COLUMN`, and every one but the first die on "duplicate
34
+ * column name". IMMEDIATE rather than the default DEFERRED because a deferred
35
+ * transaction reads first and upgrades later, and in WAL mode that upgrade
36
+ * fails at once (SQLITE_BUSY_SNAPSHOT) instead of waiting.
37
+ *
38
+ * An up-to-date database takes no write lock at all: the version is read
39
+ * unlocked first, and only a pending migration queues for the lock.
16
40
  */
17
41
  export function runMigrations(db, dir = migrationsDir()) {
18
- // Bootstrap `meta` itself so the version read below has somewhere to look.
19
- db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
20
- const current = readVersion(db);
21
42
  const files = fs
22
43
  .readdirSync(dir)
23
44
  .filter((f) => f.endsWith('.sql'))
24
- .sort();
25
- const applied = [];
26
- for (const file of files) {
45
+ .sort()
46
+ .map((file) => {
27
47
  const n = Number(file.slice(0, 3));
28
48
  if (!Number.isFinite(n) || n === 0) {
29
49
  throw new Error(`Migration filename must start with a number: ${file}`);
30
50
  }
31
- if (n <= current)
32
- continue;
33
- const sql = fs.readFileSync(path.join(dir, file), 'utf8');
51
+ return { file, n };
52
+ });
53
+ // The fast path: nothing pending, nothing locked.
54
+ const bootstrapped = hasMeta(db);
55
+ const known = bootstrapped ? readVersion(db) : 0;
56
+ if (bootstrapped && files.every((f) => f.n <= known))
57
+ return [];
58
+ const callerTimeout = Number(db.pragma('busy_timeout', { simple: true }));
59
+ db.pragma(`busy_timeout = ${Math.max(callerTimeout, MIGRATION_BUSY_TIMEOUT_MS)}`);
60
+ try {
61
+ // Bootstrap `meta` itself under the same lock as everything else.
34
62
  db.transaction(() => {
35
- db.exec(sql);
36
- writeVersion(db, n);
37
- })();
38
- applied.push(file);
63
+ db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
64
+ }).immediate();
65
+ const applied = [];
66
+ for (const { file, n } of files) {
67
+ if (n <= known)
68
+ continue;
69
+ const sql = fs.readFileSync(path.join(dir, file), 'utf8');
70
+ const ran = db.transaction(() => {
71
+ // Re-read under the lock: another process may have applied it while we
72
+ // were queued, and running an ALTER TABLE twice is an error, not a no-op.
73
+ if (readVersion(db) >= n)
74
+ return false;
75
+ db.exec(sql);
76
+ writeVersion(db, n);
77
+ return true;
78
+ }).immediate();
79
+ if (ran)
80
+ applied.push(file);
81
+ }
82
+ return applied;
83
+ }
84
+ finally {
85
+ db.pragma(`busy_timeout = ${callerTimeout}`);
39
86
  }
40
- return applied;
41
87
  }
42
88
  export function schemaVersion(db) {
43
89
  return readVersion(db);
@@ -1 +1 @@
1
- {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAErC,SAAS,WAAW,CAAC,EAAY;IAC/B,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,GAAG,CAAC,WAAW,CAEjE,CAAC;IACd,OAAO,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,SAAS,YAAY,CAAC,EAAY,EAAE,OAAe;IACjD,EAAE,CAAC,OAAO,CACR;2DACuD,CACxD,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,EAAY,EAAE,GAAG,GAAG,aAAa,EAAE;IAC/D,2EAA2E;IAC3E,EAAE,CAAC,IAAI,CAAC,6EAA6E,CAAC,CAAC;IAEvF,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAChC,MAAM,KAAK,GAAG,EAAE;SACb,WAAW,CAAC,GAAG,CAAC;SAChB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;SACjC,IAAI,EAAE,CAAC;IAEV,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACnC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,gDAAgD,IAAI,EAAE,CAAC,CAAC;QAC1E,CAAC;QACD,IAAI,CAAC,IAAI,OAAO;YAAE,SAAS;QAE3B,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;QAC1D,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;YAClB,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACb,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;QACtB,CAAC,CAAC,EAAE,CAAC;QACL,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAAY;IACxC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"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"}
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -82,4 +83,52 @@ export function projectConfigPath(repoRoot) {
82
83
  export function projectPacksDir(repoRoot) {
83
84
  return path.join(projectsDir(), projectSlug(repoRoot), 'packs');
84
85
  }
86
+ /**
87
+ * Private to the person whose history it is.
88
+ *
89
+ * `~/.eklavya` holds every prompt, edit and answer Eklavya has seen, and it was
90
+ * created 0755 with a 0644 database: readable by every other account on a
91
+ * shared machine. The directory is now 0700 and the files in it 0600.
92
+ *
93
+ * Existing installs are tightened on the next open, but only when the path is
94
+ * ours (a directory somebody pointed `EKLAVYA_HOME` at on purpose, owned by
95
+ * another account, is left as found) and never at the cost of a failure: a
96
+ * filesystem that refuses `chmod` (Windows, some network mounts) still gets a
97
+ * working Eklavya.
98
+ */
99
+ export function makePrivate(target, wanted) {
100
+ try {
101
+ const st = fs.statSync(target);
102
+ const uid = process.getuid?.();
103
+ if (uid === undefined || st.uid !== uid)
104
+ return;
105
+ if ((st.mode & 0o777 & ~wanted) !== 0)
106
+ fs.chmodSync(target, st.mode & 0o777 & wanted);
107
+ }
108
+ catch {
109
+ // Best effort, by design: see above.
110
+ }
111
+ }
112
+ /** Creates `~/.eklavya` 0700 if it is missing, and tightens it if it is looser. */
113
+ export function ensureEklavyaHome() {
114
+ const home = eklavyaHome();
115
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
116
+ makePrivate(home, 0o700);
117
+ return home;
118
+ }
119
+ /**
120
+ * High, unassigned, and deliberately boring to collide with.
121
+ *
122
+ * The low 5000s are where every dev server lands — Vite alone walks 5173, 5174,
123
+ * 5175 upward as it finds ports taken — so a default down there is a default
124
+ * you have to override. This sits above the registered services in /etc/services
125
+ * and below the 49152+ ephemeral range the OS hands out for outbound sockets,
126
+ * so neither end can claim it first. (1729 is the Hardy–Ramanujan number, which
127
+ * is as good a reason as any to remember it.)
128
+ *
129
+ * Lives here, not in `dashboard.ts`, because the SessionStart hook probes it on
130
+ * every session and importing the dashboard module drags in zod and the memory
131
+ * worker for one number.
132
+ */
133
+ export const DEFAULT_PORT = 41729;
85
134
  //# sourceMappingURL=paths.js.map
package/dist/paths.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC;;;GAGG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,UAAU,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,MAAM;IACpB,OAAO,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,cAAc,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,aAAa,CAAC,CAAC;AACjD,CAAC;AAED,0EAA0E;AAC1E,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE/D,MAAM,UAAU,aAAa;IAC3B,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;AACtC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,QAAgB;IAC1C,OAAO,QAAQ,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;AAC1C,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;AAClE,CAAC"}
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC;;;GAGG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,UAAU,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,MAAM;IACpB,OAAO,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,cAAc,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,aAAa,CAAC,CAAC;AACjD,CAAC;AAED,0EAA0E;AAC1E,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE/D,MAAM,UAAU,aAAa;IAC3B,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;AACtC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,QAAgB;IAC1C,OAAO,QAAQ,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;AAC1C,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;AAClE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,MAAc;IACxD,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAC/B,IAAI,GAAG,KAAK,SAAS,IAAI,EAAE,CAAC,GAAG,KAAK,GAAG;YAAE,OAAO;QAChD,IAAI,CAAC,EAAE,CAAC,IAAI,GAAG,KAAK,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC;YAAE,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,KAAK,GAAG,MAAM,CAAC,CAAC;IACxF,CAAC;IAAC,MAAM,CAAC;QACP,qCAAqC;IACvC,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,iBAAiB;IAC/B,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACrD,WAAW,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACzB,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.25.0",
4
+ "version": "1.25.2",
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"
@@ -15,10 +15,22 @@ drives it through `/bin/sh`.
15
15
  ## It is opt-in, twice over
16
16
 
17
17
  Nothing in `eklavya install` puts this on a repo. `scripts/install-git-hook.sh`
18
- does, as a separate step the user runs per repo; it writes `.git/hooks/pre-commit`
19
- between the `# >>> eklavya gate >>>` markers, and if a `pre-commit` already
20
- existed it moves it to `pre-commit.local` and chains it first. `--uninstall`
21
- restores it.
18
+ does, as a separate step the user runs per repo; it writes `pre-commit` in the
19
+ **common** git directory's `hooks/` (so a linked worktree gets the main
20
+ checkout's hook, which is the one git runs), between the `# >>> eklavya gate >>>`
21
+ markers, and if a `pre-commit` already existed it moves it to `pre-commit.local`
22
+ and chains it first. `--uninstall` restores it. Re-running it rewrites an older
23
+ Eklavya hook in place. With `core.hooksPath` set (husky, lefthook) it installs
24
+ nothing, prints the line to add to the manager's hook, and exits 2.
25
+
26
+ The installed hook does not `exec` this file at a fixed path any more. It runs
27
+ the first that exists of `~/.eklavya/runtime/node_modules/eklavya/dist/plugin/cli/eklavya-gate`
28
+ (honouring `EKLAVYA_RUNTIME` and `EKLAVYA_HOME`) and the path the installer ran
29
+ from, with `sh` so a lost executable bit cannot block. If neither exists the
30
+ commit goes through with one stderr line. The old fixed-path `exec` meant a
31
+ plugin update that moved the directory made every commit fail — the one failure
32
+ this gate must never have. `eklavya uninstall` warns about the hook and prints
33
+ the command to remove it; it never deletes it.
22
34
 
23
35
  Then the script itself only acts on a project whose config sets `quiz.enforced`
24
36
  (or the retired `"mode": "enforced"`, which it still reads). That config is at
@@ -23,6 +23,21 @@ back to `npx eklavya@<pinned> serve` and a hook starts a detached background
23
23
  `npm install` (once an hour at most, claimed by a `.installing` stamp before
24
24
  spawning) and exits 0 saying nothing.
25
25
 
26
+ The same heal keeps an **installed** runtime current. The plugin moves when
27
+ Claude Code updates it; the runtime only moves when npm runs. So when the entry
28
+ resolved to `~/.eklavya/runtime` and its `package.json` version is older than
29
+ `plugin.json`'s, `healIfBehind` starts that background install before the
30
+ import — this run still uses the old runtime and never waits. It never
31
+ downgrades (migrations only go forward), and leaves an `EKLAVYA_RUNTIME` or
32
+ checkout build alone. `eklavya doctor`'s `versions` row reports the skew.
33
+
34
+ The `npx` fallback retries `eklavya@latest` once, and only when npm says the
35
+ pinned version does not exist (`E404`/`ETARGET`/`No matching version`). That is
36
+ the release gap: semantic-release pushes the bumped `plugin.json` in its
37
+ `prepare` step, before `npm publish`, so a marketplace pull can pin a version
38
+ npm does not have yet for a minute or so. A server that started and then failed
39
+ is not restarted.
40
+
26
41
  **It carries no version number.** It reads `.claude-plugin/plugin.json` at
27
42
  runtime, and `mcp/test/packaging.test.ts` asserts the file matches no
28
43
  `\d+\.\d+\.\d+` anywhere and does match `plugin\.json` — so writing any dotted
@@ -32,12 +47,15 @@ else.
32
47
 
33
48
  ## The seven hooks, out of `hooks.json`
34
49
 
50
+ Seven scripts on six events: `checkpoint-quiz` is registered twice under
51
+ PostToolUse, so the table has eight rows.
52
+
35
53
  | Event | Matcher | Timeout | Script | Job |
36
54
  |---|---|---|---|---|
37
- | SessionStart | — | 10s | `session-start` | stamp this checkout's session pointer (`meta.current_session:<repo root>`), replay the spool, summarise the last session's batch, recall this project's memory, show the developer the profile banner (`systemMessage`) and hand the model the recall and the standing log directive (`additionalContext`) |
55
+ | SessionStart | — | 10s | `session-start` | stamp this checkout's session pointer (`meta.current_session:<repo root>`), replay the spool, summarise the last session's batch, recall this project's memory, show the developer the profile banner (`systemMessage`) and hand the model the recall and the standing log directive (`additionalContext`). A database that exists but will not open gets one line to the developer — `Eklavya paused · can't open its database · run: eklavya doctor`, or the SQLite/Node-mismatch variant — and a first run with no database stays silent |
38
56
  | UserPromptSubmit | — | 10s | `prompt-submit-nudge` | re-stamp this checkout's session pointer, then re-state the log directive in one line, but only for a session that has logged nothing after a grace window |
39
57
  | SubagentStart | — | 10s | `subagent-start` | give a delegated agent the log directive the parent's SessionStart never reached it with |
40
- | PreToolUse | `Bash` | 10s | `pre-tool-gate` | with `quiz.enforced` only, deny a `git commit` whose session gate has not passed |
58
+ | PreToolUse | `Bash` | 10s | `pre-tool-gate` | with `quiz.enforced` only, deny a `git commit` (or `git merge --continue`) whose session gate has not passed. `commit-lib.ts` lexes the command like a shell — newlines, `env`/`sudo`/`timeout`/`VAR=x` prefixes, `bash -c`, subshells, substitutions, `git -C dir` — and accepts missing aliases and scripts: the git hook is the real enforcement |
41
59
  | PostToolUse | — | 10s | `capture-tool` | record the tool use as memory evidence |
42
60
  | PostToolUse | `mcp__.*log_session_concepts` | 10s | `checkpoint-quiz` | one mid-task question, `interleaved` cadence only |
43
61
  | PostToolUse | `^(Bash\|Edit\|Write\|MultiEdit\|NotebookEdit)$` | 10s | `checkpoint-quiz` | the same hook, re-armed by the work: the model logs once per task, so without this the checkpoint asked once per task. No `statusMessage` — it would flash on every command |
@@ -59,7 +77,19 @@ word `off` denied it, so `session-start` now says on screen which half stopped.
59
77
  the prompt; `capture-tool` captures the tool use; `stop-quiz-check` closes the
60
78
  batch at the seam. `mcp/src/hooks/memory-lib.ts` holds the shared helpers, and
61
79
  every one of them swallows its own failures — a capture path that throws is a
62
- throw on every tool call.
80
+ throw on every tool call. The light capture path — `identityOf`, `record`,
81
+ `batchIfFull` — lives in `capture-lib.ts`, so `capture-tool` and
82
+ `prompt-submit-nudge` never import the worker, the provider, recall or notify;
83
+ `memory-lib.ts` re-exports them for the seam hooks, and
84
+ `mcp/test/hook-isolation.test.ts` fails if a hot path starts loading a heavy
85
+ module again.
86
+
87
+ A Stop seam does not close a batch every turn: only at `SEAM_MIN_EVENTS` (8)
88
+ or once the oldest open event is `SEAM_MAX_AGE_MS` (20 minutes) old, because
89
+ with an observer configured every closed batch is a `claude -p` call. A session
90
+ start closes everything. The same seam runs the retention sweep
91
+ (`pruneIfDue`, at most every six hours, 5,000 events at a time) and gives all
92
+ notification sinks one shared 5s budget (`NOTIFY_BUDGET_MS`).
63
93
 
64
94
  The seam never waits on inference. With `providers.observer` configured,
65
95
  `flushAtSeam` queues the batch and hands it to a detached `eklavya memory
@@ -30,6 +30,11 @@
30
30
  * alone gets you a working MCP server immediately, and the first session heals
31
31
  * the rest in the background.
32
32
  *
33
+ * The same heal keeps rule 3 current. The plugin updates itself through Claude
34
+ * Code (a marketplace pull, `/plugin update`); the runtime only moves when npm
35
+ * runs. So when the runtime is older than the plugin pins, this run still uses
36
+ * it — never blocking — and a background install brings it up to the pin.
37
+ *
33
38
  * Hard rule: a hook must never break a session. Everything here
34
39
  * fails to exit 0 in silence.
35
40
  */
@@ -60,6 +65,44 @@ function pinnedVersion() {
60
65
  }
61
66
  }
62
67
 
68
+ /** The version of the runtime `eklavya install` wrote, or null. One small JSON read. */
69
+ function runtimeVersion() {
70
+ try {
71
+ const manifest = path.join(runtimeHome, 'node_modules', 'eklavya', 'package.json');
72
+ return JSON.parse(readFileSync(manifest, 'utf8')).version ?? null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** True when version `a` is older than `b`, comparing the numeric parts; a prerelease tag is ignored. */
79
+ function olderThan(a, b) {
80
+ const parts = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0);
81
+ const [x, y] = [parts(a), parts(b)];
82
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
83
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
84
+ if (d !== 0) return d < 0;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Starts the background heal when the runtime at `entry` is behind the plugin.
91
+ *
92
+ * Only for the runtime directory: an EKLAVYA_RUNTIME build or a development
93
+ * checkout is somebody's deliberate choice. And only when it is BEHIND — a
94
+ * runtime ahead of the plugin (a plugin rolled back, or updated late) is left
95
+ * alone, because migrations only go forward and an older runtime may not
96
+ * understand a database the newer one has already moved. `eklavya doctor`
97
+ * reports the skew either way.
98
+ */
99
+ function healIfBehind(entry) {
100
+ if (!entry.startsWith(path.join(runtimeHome, 'node_modules', 'eklavya') + path.sep)) return;
101
+ const installed = runtimeVersion();
102
+ const pinned = pinnedVersion();
103
+ if (installed && pinned && olderThan(installed, pinned)) healInBackground();
104
+ }
105
+
63
106
  /** The compiled entry point for `name`, or null if no build is reachable. */
64
107
  function resolveEntry() {
65
108
  const relative = name === 'server' ? ['dist', 'server.js'] : ['dist', 'hooks', `${name}.js`];
@@ -147,6 +190,9 @@ async function main() {
147
190
  const entry = resolveEntry();
148
191
 
149
192
  if (entry) {
193
+ // Before the import: a server never returns from it, and the heal is a
194
+ // detached spawn that costs this run nothing.
195
+ healIfBehind(entry);
150
196
  await import(pathToFileURL(entry).href);
151
197
  return;
152
198
  }
@@ -157,12 +203,30 @@ async function main() {
157
203
  // land there.
158
204
  const version = pinnedVersion();
159
205
  if (!version) process.exit(0);
206
+ //
207
+ // A release pushes the bumped plugin.json before `npm publish` finishes
208
+ // (semantic-release commits in its prepare step and publishes after), so a
209
+ // marketplace pull can pin a version npm does not have for a minute or so.
210
+ // Only that failure — npm saying the version does not exist — retries with
211
+ // `latest`; a server that started and then failed is not restarted.
160
212
  const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
161
- const child = spawn(npx, ['--yes', `eklavya@${version}`, 'serve'], {
162
- stdio: ['inherit', 'inherit', 'ignore'],
163
- shell: process.platform === 'win32',
164
- });
165
- child.on('exit', (code) => process.exit(code ?? 0));
213
+ const serve = (spec, retry) => {
214
+ const child = spawn(npx, ['--yes', `eklavya@${spec}`, 'serve'], {
215
+ stdio: ['inherit', 'inherit', 'pipe'],
216
+ shell: process.platform === 'win32',
217
+ });
218
+ let errText = '';
219
+ // Kept draining past the cap, or a chatty server would block on stderr.
220
+ child.stderr.on('data', (chunk) => {
221
+ if (errText.length < 64 * 1024) errText += chunk;
222
+ });
223
+ child.on('error', () => process.exit(0));
224
+ child.on('exit', (code) => {
225
+ if (code && retry && /E404|ETARGET|No matching version/i.test(errText)) serve('latest', false);
226
+ else process.exit(code ?? 0);
227
+ });
228
+ };
229
+ serve(version, true);
166
230
  return;
167
231
  }
168
232
 
@@ -4,6 +4,11 @@
4
4
  # replacing it.
5
5
  #
6
6
  # scripts/install-git-hook.sh [--uninstall] [repo-path]
7
+ #
8
+ # Exit status: 0 installed, updated, already there, or uninstalled; 1 an error
9
+ # (not a repository, or two existing hooks it will not choose between); 2 not
10
+ # installed because core.hooksPath hands hooks to a hook manager -- the message
11
+ # says how to call the gate from there instead.
7
12
 
8
13
  set -eu
9
14
 
@@ -14,69 +19,154 @@ REPO_ARG=""
14
19
  for arg in "$@"; do
15
20
  case "$arg" in
16
21
  --uninstall) UNINSTALL=1 ;;
17
- -h|--help) sed -n '2,8p' "$0"; exit 0 ;;
22
+ -h|--help) sed -n '2,11p' "$0"; exit 0 ;;
18
23
  *) REPO_ARG=$arg ;;
19
24
  esac
20
25
  done
21
26
 
22
- CLI_PATH=$(cd "$(dirname "$0")/../cli" && pwd)/eklavya-gate
23
- REPO=${REPO_ARG:-$(git rev-parse --show-toplevel 2>/dev/null || true)}
27
+ # Not `cd ../cli`: under `set -e` a missing cli/ would abort even --uninstall.
28
+ CLI_PATH=$(cd "$(dirname "$0")/.." && pwd -P)/cli/eklavya-gate
24
29
 
30
+ # A path argument may be anywhere inside the checkout; git names the top.
31
+ REPO=$(cd "${REPO_ARG:-.}" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null) || REPO=''
25
32
  if [ -z "$REPO" ]; then
26
- printf 'Not inside a git repository, and no path given.\n' >&2
33
+ if [ -n "$REPO_ARG" ]; then
34
+ printf '%s is not inside a git repository.\n' "$REPO_ARG" >&2
35
+ else
36
+ printf 'Not inside a git repository, and no path given.\n' >&2
37
+ fi
27
38
  exit 1
28
39
  fi
29
40
 
30
- HOOK_DIR="$REPO/.git/hooks"
41
+ # Hooks live in the *common* git directory. In a linked worktree `.git` is a
42
+ # file, not a directory, and every worktree runs the main checkout's hooks, so
43
+ # `$REPO/.git/hooks` is wrong there. `--git-common-dir` may answer relative to
44
+ # the checkout, hence the `cd` before resolving it.
45
+ COMMON=$(cd "$REPO" && git rev-parse --git-common-dir 2>/dev/null) || COMMON=''
46
+ COMMON=$(cd "$REPO" && cd "$COMMON" 2>/dev/null && pwd -P) || COMMON=''
47
+ if [ -z "$COMMON" ]; then
48
+ printf 'Could not find the git directory for %s.\n' "$REPO" >&2
49
+ exit 1
50
+ fi
51
+ HOOK_DIR="$COMMON/hooks"
31
52
  HOOK="$HOOK_DIR/pre-commit"
32
53
  CHAINED="$HOOK_DIR/pre-commit.local"
33
54
 
55
+ # "Is something there", counting a symlink whose target is gone: `[ -f ]` says
56
+ # no to that, and writing the hook would then write through the dangling link.
57
+ exists() { [ -e "$1" ] || [ -L "$1" ]; }
58
+ ours() { [ -f "$1" ] && grep -q "$MARKER" "$1" 2>/dev/null; }
59
+
34
60
  if [ "$UNINSTALL" -eq 1 ]; then
35
- if [ -f "$HOOK" ] && grep -q "$MARKER" "$HOOK" 2>/dev/null; then
61
+ if ours "$HOOK"; then
36
62
  rm -f "$HOOK"
37
- if [ -f "$CHAINED" ]; then
63
+ if exists "$CHAINED"; then
38
64
  mv "$CHAINED" "$HOOK"
39
65
  printf 'Removed the Eklavya gate and restored your previous pre-commit hook.\n'
40
66
  else
41
67
  printf 'Removed the Eklavya gate.\n'
42
68
  fi
43
69
  else
44
- printf 'No Eklavya gate installed here.\n'
70
+ printf 'No Eklavya gate installed in %s.\n' "$HOOK_DIR"
45
71
  fi
46
72
  exit 0
47
73
  fi
48
74
 
49
- mkdir -p "$HOOK_DIR"
75
+ # A hook manager (husky, lefthook, pre-commit) sets core.hooksPath, and git
76
+ # then never runs $HOOK. Installing there anyway would report a gate that never
77
+ # fires; writing into the manager's directory would put Eklavya into a folder
78
+ # that is usually committed. So neither: say how to call the gate from the
79
+ # manager, and exit non-zero so nobody mistakes this for an installed gate.
80
+ # The printed line must fail open too, and must not be the `[ -f X ] && sh X`
81
+ # shape: pasted last in a hook, a missing file makes that line -- and so the
82
+ # hook -- exit 1, blocking every commit. `if` exits 0 when its test fails.
83
+ HOOKS_PATH=$(cd "$REPO" && git config --get core.hooksPath 2>/dev/null) || HOOKS_PATH=''
84
+ if [ -n "$HOOKS_PATH" ]; then
85
+ cat >&2 <<EOF
86
+ Not installed: this repository sets core.hooksPath to "$HOOKS_PATH" (a hook
87
+ manager such as husky or lefthook), so git never runs $HOOK.
50
88
 
51
- if [ -f "$HOOK" ] && grep -q "$MARKER" "$HOOK" 2>/dev/null; then
52
- printf 'Eklavya gate already installed in %s\n' "$HOOK"
53
- exit 0
89
+ To gate commits, add this line to the pre-commit hook your manager runs:
90
+
91
+ g="\${EKLAVYA_RUNTIME:-\${EKLAVYA_HOME:-\$HOME/.eklavya}/runtime}/node_modules/eklavya/dist/plugin/cli/eklavya-gate"; if [ -f "\$g" ]; then sh "\$g" || exit \$?; fi
92
+
93
+ It holds a commit only when this project sets quiz.enforced and the session
94
+ quiz has not passed. It names the installed runtime copy, which plugin updates
95
+ keep at the same path, and if that file is ever missing the line does nothing,
96
+ so your commits keep working.
97
+ EOF
98
+ exit 2
54
99
  fi
55
100
 
56
- # Preserve whatever was there. The existing hook keeps running, first.
57
- if [ -f "$HOOK" ]; then
58
- if [ -f "$CHAINED" ]; then
59
- printf 'Refusing to overwrite: both %s and %s already exist.\n' "$HOOK" "$CHAINED" >&2
60
- exit 1
101
+ mkdir -p "$HOOK_DIR"
102
+
103
+ if ours "$HOOK"; then
104
+ UPGRADE=1
105
+ else
106
+ UPGRADE=0
107
+ # Preserve whatever was there. The existing hook keeps running, first.
108
+ if exists "$HOOK"; then
109
+ if exists "$CHAINED"; then
110
+ # Two hooks and one slot: moving either would lose one, so neither moves.
111
+ printf 'Not installed: %s and %s both exist, and neither is the Eklavya gate.\n' "$HOOK" "$CHAINED" >&2
112
+ printf 'Merge them into one pre-commit hook, then run this again.\n' >&2
113
+ exit 1
114
+ fi
115
+ mv "$HOOK" "$CHAINED"
116
+ printf 'Moved your existing pre-commit hook to %s; it will still run first.\n' "$CHAINED"
61
117
  fi
62
- mv "$HOOK" "$CHAINED"
63
- printf 'Moved your existing pre-commit hook to %s; it will still run first.\n' "$CHAINED"
64
118
  fi
65
119
 
66
- cat > "$HOOK" <<EOF
120
+ # Where the hook looks for the gate at commit time, in order:
121
+ #
122
+ # 1. the installed runtime, ~/.eklavya/runtime/.../dist/plugin/cli/. It is
123
+ # the copy `eklavya install` and the plugin keep current, at a path that
124
+ # does not change between versions;
125
+ # 2. the path this installer ran from, which a plugin update may delete.
126
+ #
127
+ # If neither is there the commit goes through with one line on stderr. The hook
128
+ # used to `exec` path 2 unconditionally, so the next plugin update made every
129
+ # commit in the repository fail -- the one failure this gate must never have.
130
+ # Run with `sh` rather than exec'd, so a lost executable bit cannot block either.
131
+ # Escaped for the double-quoted string it lands in, so a checkout path holding
132
+ # a quote, backslash, backtick or dollar sign cannot break (or run code in) it.
133
+ CLI_QUOTED=$(printf '%s' "$CLI_PATH" | sed 's/[\\"$`]/\\&/g')
134
+ TMP="$HOOK_DIR/.pre-commit.eklavya-tmp.$$"
135
+ trap 'rm -f "$TMP"' EXIT
136
+ cat > "$TMP" <<EOF
67
137
  #!/bin/sh
68
138
  $MARKER
69
- # Installed by Eklavya. Remove with scripts/install-git-hook.sh --uninstall
139
+ # Installed by Eklavya. Remove with "install-git-hook.sh --uninstall" from the
140
+ # plugin's scripts/ directory, or delete this file.
70
141
 
71
142
  if [ -x "\$(dirname "\$0")/pre-commit.local" ]; then
72
143
  "\$(dirname "\$0")/pre-commit.local" "\$@" || exit \$?
73
144
  fi
74
145
 
75
- exec "$CLI_PATH"
146
+ for gate in \\
147
+ "\${EKLAVYA_RUNTIME:-\${EKLAVYA_HOME:-\${HOME:-}/.eklavya}/runtime}/node_modules/eklavya/dist/plugin/cli/eklavya-gate" \\
148
+ "$CLI_QUOTED"
149
+ do
150
+ [ -f "\$gate" ] && exec sh "\$gate"
151
+ done
152
+
153
+ printf 'eklavya: commit gate not found, so this commit is not gated. Run "npx eklavya install" to restore it, or delete %s to stop this message.\\n' "\$0" >&2
154
+ exit 0
76
155
  # <<< eklavya gate <<<
77
156
  EOF
157
+ chmod 755 "$TMP"
158
+
159
+ if [ "$UPGRADE" -eq 1 ] && cmp -s "$TMP" "$HOOK"; then
160
+ printf 'Eklavya gate already installed in %s\n' "$HOOK"
161
+ exit 0
162
+ fi
163
+ mv -f "$TMP" "$HOOK"
164
+ trap - EXIT
78
165
 
79
- chmod +x "$HOOK"
80
- printf 'Installed the Eklavya commit gate in %s\n' "$HOOK"
166
+ if [ "$UPGRADE" -eq 1 ]; then
167
+ printf 'Updated the Eklavya commit gate in %s\n' "$HOOK"
168
+ else
169
+ printf 'Installed the Eklavya commit gate in %s\n' "$HOOK"
170
+ fi
81
171
  printf 'It only acts on projects whose config sets quiz.enforced. That config lives at\n'
82
172
  printf '~/.eklavya/projects/<checkout>/config.json, never inside the repository.\n'
@@ -40,7 +40,7 @@ So, for `tutor` and anything else without `disable-model-invocation`:
40
40
  number of questions, never the order of the tool calls, never the grading.
41
41
  Those live in the body, which is where the model has to go to get them.
42
42
 
43
- The eight slash commands are exempt, and it is not a technicality:
43
+ The nine slash commands are exempt, and it is not a technicality:
44
44
  `disable-model-invocation: true` means the model never matches on their
45
45
  description at all. The developer types the command and the description is its
46
46
  one line of help, so those should say what they do. `agents/tutor.md` keeps one
@@ -8,7 +8,9 @@ disable-model-invocation: true
8
8
 
9
9
  Get Eklavya working on this machine. Be brief; this should take one exchange.
10
10
 
11
- **1. Check prerequisites.** Run `node --version`. That is the whole list — the server, the CLI and all seven hooks are Node, so nothing else has to be on `PATH`. Node must be 22+; below that the SQLite driver has no prebuilt binary and would need a C++ toolchain to install. If it is older, say so and how to upgrade on this platform, and stop: the rest of setup will not work.
11
+ **1. Check prerequisites.** Run `node --version`. Node is the only hard requirement — the server, the CLI and all seven hooks are Node. It must be 22+; below that the SQLite driver has no prebuilt binary and would need a C++ toolchain to install. If it is older, say so and how to upgrade on this platform, and stop: the rest of setup will not work.
12
+
13
+ Two command-line tools matter for less: `sqlite3`, which step 2 uses to look at the database, and `jq`, which together with `sqlite3` the terminal commit gate in step 4 needs. Check with `command -v sqlite3 jq`. Missing ones do not stop setup — say which is missing and how to install it on this platform, skip the shell check in step 2 if it is `sqlite3`, and if they choose enforced, say plainly that commits made outside Claude Code will not be gated until both are installed. The gate lets commits through without them rather than blocking.
12
14
 
13
15
  Optionally run `eklavya doctor`, which reports the same thing plus the runtime, the SQLite driver, the plugin's registration, the chat skill, the database and the effective config. It exits non-zero and names the repair — `eklavya install` — if any of those has broken. That is also the command to reach for later, whenever Eklavya has gone quiet: the hooks never fail loudly, so a broken install looks exactly like a quiet one.
14
16
 
@@ -46,7 +48,12 @@ The `PreToolUse` hook only covers commits made inside Claude Code — which incl
46
48
  "${CLAUDE_PLUGIN_ROOT}"/scripts/install-git-hook.sh
47
49
  ```
48
50
 
49
- It chains to any existing `pre-commit` hook rather than replacing it, and only acts on projects whose config sets `quiz.enforced` (or the retired `"mode": "enforced"`) — so installing it is safe even if they turn enforcement off later. Mention `--uninstall` restores the previous hook.
51
+ It works from a linked worktree too (all worktrees of a repository share one hook), chains to any existing `pre-commit` hook rather than replacing it, and only acts on projects whose config sets `quiz.enforced` (or the retired `"mode": "enforced"`) — so installing it is safe even if they turn enforcement off later. If the plugin moves or updates, the hook finds the gate in `~/.eklavya/runtime`, and if it finds none it lets the commit through with a one-line warning; it never blocks a commit because a file is missing. Mention `--uninstall` restores the previous hook.
52
+
53
+ Read its exit status rather than assuming it worked:
54
+
55
+ - **2** — the repository sets `core.hooksPath` (husky, lefthook and similar), so git would never run the hook and nothing was installed. Relay the line it printed: they add it to the `pre-commit` hook their manager runs. Do not edit their hook manager's files yourself — those are usually committed.
56
+ - **1** — not a git repository, or a `pre-commit` and a `pre-commit.local` both already exist and it will not choose between them. Relay the message; nothing was changed.
50
57
 
51
58
  Skip this step unless they chose enforced.
52
59
 
@@ -178,6 +178,13 @@ into the batch it replaced.
178
178
  once: the gate exists, here is what it needs, let's get through it. Supportive,
179
179
  not punitive. Never imply they are being punished.
180
180
 
181
+ While the gate is open the plan holds only this session's own work — no
182
+ review, no widening, since neither counts toward the gate. Items with
183
+ `reason: "gate_work"` are this session's concepts even when the learner has
184
+ mastered them in another session since: they are on the gate's list, so ask
185
+ them normally. Do not skip one because the profile says it is mastered, or
186
+ the gate can become impossible to pass.
187
+
181
188
  A blank grades 0, and 0 never passes the gate — so a session answered entirely
182
189
  with "I don't know" would leave nothing to ask and a commit that can never go
183
190
  through. When that happens the plan comes back with `reason: "gate_retry"`: the