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.
- package/dist/assets/dashboard.html +1 -1
- package/dist/assets/tutor/references/focus-and-level.md +7 -0
- package/dist/claude-mem.js +9 -7
- package/dist/claude-mem.js.map +1 -1
- package/dist/cli-memory.js +786 -0
- package/dist/cli-memory.js.map +1 -0
- package/dist/cli.js +95 -773
- package/dist/cli.js.map +1 -1
- package/dist/config.js +79 -13
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +36 -14
- package/dist/dashboard.js.map +1 -1
- package/dist/db.js +33 -6
- package/dist/db.js.map +1 -1
- package/dist/hooks/capture-lib.js +55 -0
- package/dist/hooks/capture-lib.js.map +1 -0
- package/dist/hooks/capture-tool.js +1 -1
- package/dist/hooks/capture-tool.js.map +1 -1
- package/dist/hooks/commit-lib.js +255 -0
- package/dist/hooks/commit-lib.js.map +1 -0
- package/dist/hooks/lib.js +37 -5
- package/dist/hooks/lib.js.map +1 -1
- package/dist/hooks/memory-lib.js +93 -78
- package/dist/hooks/memory-lib.js.map +1 -1
- package/dist/hooks/pre-tool-gate.js +5 -10
- package/dist/hooks/pre-tool-gate.js.map +1 -1
- package/dist/hooks/prompt-submit-nudge.js +26 -1
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +13 -4
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/install.js +280 -67
- package/dist/install.js.map +1 -1
- package/dist/memory/capture.js +25 -5
- package/dist/memory/capture.js.map +1 -1
- package/dist/memory/privacy.js +105 -7
- package/dist/memory/privacy.js.map +1 -1
- package/dist/memory/provider.js +13 -3
- package/dist/memory/provider.js.map +1 -1
- package/dist/memory/recall.js +18 -6
- package/dist/memory/recall.js.map +1 -1
- package/dist/memory/spool.js +105 -23
- package/dist/memory/spool.js.map +1 -1
- package/dist/memory/store.js +32 -2
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/summarize.js +5 -5
- package/dist/memory/summarize.js.map +1 -1
- package/dist/memory/worker.js +71 -10
- package/dist/memory/worker.js.map +1 -1
- package/dist/migrate.js +60 -14
- package/dist/migrate.js.map +1 -1
- package/dist/paths.js +49 -0
- package/dist/paths.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/cli/CLAUDE.md +16 -4
- package/dist/plugin/hooks/CLAUDE.md +33 -3
- package/dist/plugin/hooks/run.mjs +69 -5
- package/dist/plugin/scripts/install-git-hook.sh +114 -24
- package/dist/plugin/skills/CLAUDE.md +1 -1
- package/dist/plugin/skills/setup/SKILL.md +9 -2
- package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
- package/dist/safe-write.js +146 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/slug.js +4 -1
- package/dist/slug.js.map +1 -1
- package/dist/srs.js +33 -1
- package/dist/srs.js.map +1 -1
- package/dist/store.js +29 -20
- package/dist/store.js.map +1 -1
- package/dist/tools/config_tools.js +23 -1
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +51 -30
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/tools/log_session_concepts.js +30 -23
- package/dist/tools/log_session_concepts.js.map +1 -1
- package/dist/tools/record_attempt.js +18 -8
- package/dist/tools/record_attempt.js.map +1 -1
- package/dist/tools/types.js +29 -0
- package/dist/tools/types.js.map +1 -1
- package/dist/tools/upsert_concepts.js +12 -10
- package/dist/tools/upsert_concepts.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +26 -10
- 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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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);
|
package/dist/migrate.js.map
CHANGED
|
@@ -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
|
|
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":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eklavya",
|
|
3
3
|
"displayName": "Eklavya",
|
|
4
|
-
"version": "1.25.
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,
|
|
22
|
+
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
|
18
23
|
*) REPO_ARG=$arg ;;
|
|
19
24
|
esac
|
|
20
25
|
done
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
+
if ours "$HOOK"; then
|
|
36
62
|
rm -f "$HOOK"
|
|
37
|
-
if
|
|
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
|
|
70
|
+
printf 'No Eklavya gate installed in %s.\n' "$HOOK_DIR"
|
|
45
71
|
fi
|
|
46
72
|
exit 0
|
|
47
73
|
fi
|
|
48
74
|
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
exit
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
printf '
|
|
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
|
|
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`.
|
|
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
|