From fe54e41a4b3a8276e0782ac5adace134d4a50334 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Thu, 29 Oct 2026 01:32:49 +0100 Subject: [PATCH 1/3] Start hooks through an .mjs launcher that enables Node's compile cache - Each registered hook runs a small .mjs that calls enableCompileCache and imports its .mts, cutting ~30 ms of type stripping per start - The .mts modules install as plain file components; a contract test keeps every node registration on .mjs --- CLAUDE.md | 2 +- README.md | 2 ++ biome.json | 1 + hooks/kb-gate.mjs | 7 ++++++ hooks/sync-memories.mjs | 7 ++++++ techpack.yaml | 48 ++++++++++++++++++++++++++-------------- tests/contracts.test.mts | 18 +++++++++++---- 7 files changed, 64 insertions(+), 21 deletions(-) create mode 100644 hooks/kb-gate.mjs create mode 100644 hooks/sync-memories.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 85a12ca..05e0bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ The consequence that matters most: **nothing here executes from the repo.** Edit **Run the suite twice when touching state handling** — CI does (`.github/workflows/ci.yml`). The gate's barrier is scoped by a monotonic turn counter rather than wall-clock time. -Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. +Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; the cache keeps the stripped module until its content changes. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. Two harness details are load-bearing rather than incidental: diff --git a/README.md b/README.md index adb6395..ef615c2 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,10 @@ memory/ ├── techpack.yaml # Manifest — defines all components ├── config/settings.json # Disables built-in auto-memory ├── hooks/ +│ ├── sync-memories.mjs # Launcher: enables Node's compile cache, then loads the .mts │ ├── sync-memories.mts # Memory indexing/reindexing │ ├── memory-loop-activator.sh # Knowledge extraction reminder +│ ├── kb-gate.mjs # Launcher: enables Node's compile cache, then loads the .mts │ ├── kb-gate.mts # Keeps KB lookups ahead of delegated discovery │ └── shared.mts # Shared filesystem and project-path helpers ├── scripts/ # Maintainer checks diff --git a/biome.json b/biome.json index 0e58158..721ab91 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,7 @@ "files": { "includes": [ "hooks/**/*.mts", + "hooks/**/*.mjs", "scripts/**/*.mts", "tests/**/*.mts", "tests/fixtures/*.json", diff --git a/hooks/kb-gate.mjs b/hooks/kb-gate.mjs new file mode 100644 index 0000000..59eef4d --- /dev/null +++ b/hooks/kb-gate.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning +import { enableCompileCache } from "node:module"; + +// Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so +// this launcher stays .mjs and the cache keeps the stripped module until it changes. +enableCompileCache(); +await import("./kb-gate.mts"); diff --git a/hooks/sync-memories.mjs b/hooks/sync-memories.mjs new file mode 100644 index 0000000..2491d57 --- /dev/null +++ b/hooks/sync-memories.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning +import { enableCompileCache } from "node:module"; + +// Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so +// this launcher stays .mjs and the cache keeps the stripped module until it changes. +enableCompileCache(); +await import("./sync-memories.mts"); diff --git a/techpack.yaml b/techpack.yaml index 43335e6..2310839 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -218,6 +218,22 @@ components: source: hooks/shared.mts destination: shared.mts + # Registered hooks run a small .mjs launcher that turns on Node's compile cache before + # importing these, so the TypeScript is stripped once instead of on every start. + - id: hook-sync-memories-module + displayName: Sync memories module + description: Indexing logic loaded by the sync memories launcher + hook: + source: hooks/sync-memories.mts + destination: sync-memories.mts + + - id: hook-kb-gate-module + displayName: KB gate module + description: KB gate logic loaded by the KB gate launchers + hook: + source: hooks/kb-gate.mts + destination: kb-gate.mts + - id: hook-continuous-learning displayName: Memory loop activator description: Reminds to evaluate learnings on each prompt @@ -235,8 +251,8 @@ components: hookTimeout: 120 hookStatusMessage: "Indexing memories..." hook: - source: hooks/sync-memories.mts - destination: sync-memories.mts + source: hooks/sync-memories.mjs + destination: sync-memories.mjs - id: hook-reindex-memories displayName: Reindex memories hook @@ -247,8 +263,8 @@ components: hookTimeout: 120 hookStatusMessage: "Reindexing memories..." hook: - source: hooks/sync-memories.mts - destination: sync-memories.mts + source: hooks/sync-memories.mjs + destination: sync-memories.mjs # ── KB-first delegation ───────────────────────────────────────────────── # Several registrations of one dispatcher script, which branches on @@ -267,8 +283,8 @@ components: hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: UserPromptSubmit hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs # Checked because losing this one corrupts the gate rather than disabling it: # with no turn stamp the counter reads 0 forever, so every search ever made in # the session counts as fresh and the barrier silently widens from per-turn to @@ -286,8 +302,8 @@ components: hookEvent: PostToolUse hookMatcher: "mcp__memory-loop__query|Skill" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs # Same reasoning: without this, no search or skill is ever recorded, so the # gate is pure friction until the budget releases it. doctorChecks: @@ -305,8 +321,8 @@ components: # builds. Matching only `Task` silently matches nothing. hookMatcher: "Agent|Task|Write|Edit|NotebookEdit|Bash" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs doctorChecks: - type: hookEventExists name: "PreToolUse hook registered" @@ -320,8 +336,8 @@ components: hookEvent: SessionStart hookMatcher: "compact" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs - id: hook-kb-gate-retrieval displayName: KB gate — record memory retrievals @@ -332,8 +348,8 @@ components: # Every Read starts this hook, and it never needs to block, so it stays off the turn's critical path. hookAsync: true hook: - source: hooks/kb-gate.mts - destination: kb-usage.mts + source: hooks/kb-gate.mjs + destination: kb-usage.mjs - id: hook-kb-subagent-brief displayName: Sub-agent KB brief @@ -341,8 +357,8 @@ components: hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: SubagentStart hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs doctorChecks: - type: hookEventExists name: "SubagentStart hook registered" diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index d40c09f..d2ebc30 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -40,14 +40,24 @@ for (const type of ["commonjs", "module"]) { assert.match(manifest, /hook:\n {6}source: hooks\/shared.mts\n {6}destination: shared.mts/); const installed = `${f.project}/.claude/hooks/memory`; mkdirSync(installed, { recursive: true }); - cpSync(`${root}/hooks/shared.mts`, `${installed}/shared.mts`); + for (const module of ["shared", "kb-gate", "sync-memories"]) { + assert.match(manifest, new RegExp(`hook:\\n {6}source: hooks/${module}.mts\\n {6}destination: ${module}.mts`)); + writeFileSync( + `${installed}/${module}.mts`, + readFileSync(`${root}/hooks/${module}.mts`, "utf8").replace("__KB_GATE_MODE__", "enforce"), + ); + } for (const component of hooks) { const source = field(component, "source"); const destination = field(component, "destination"); const event = field(component, "hookEvent"); if (destination === "memory-loop-activator.sh") assert.doesNotMatch(component, /hookInterpreter:/); - else assert.match(component, /hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning/); - writeFileSync(`${installed}/${destination}`, readFileSync(`${root}/${source}`, "utf8").replace("__KB_GATE_MODE__", "enforce")); + else { + assert.match(component, /hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning/); + // A registered .mts would strip its types on every start, before any launcher could enable the cache. + assert.match(destination, /\.mjs$/); + } + cpSync(`${root}/${source}`, `${installed}/${destination}`); const output = f.run( `${installed}/${destination}`, { @@ -62,7 +72,7 @@ for (const type of ["commonjs", "module"]) { if (event === "SubagentStart") assert.equal(JSON.parse(output).hookSpecificOutput.hookEventName, event); } assert.match(manifest, /hookMatcher: "Agent\|Task\|Write\|Edit\|NotebookEdit\|Bash"/); - assert.match(manifest, /hookEvent: SessionStart\n {4}hookMatcher: "compact"\n {4}hook:\n {6}source: hooks\/kb-gate.mts/); + assert.match(manifest, /hookEvent: SessionStart\n {4}hookMatcher: "compact"\n {4}hook:\n {6}source: hooks\/kb-gate.mjs/); assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|Skill"/); assert.match(manifest, /hookMatcher: "mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Read"/); assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 3); From 1ed0c897e04d59b1edcbc30c0d27abcd324442c4 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 1 Nov 2026 00:32:54 +0100 Subject: [PATCH 2/3] Only enable the compile cache where Node provides it - Launchers feature-detect module.enableCompileCache, which Node added in 22.8 - Node 22.6 and 22.7 now load the hooks without the cache instead of failing to link --- CLAUDE.md | 2 +- hooks/kb-gate.mjs | 5 +++-- hooks/sync-memories.mjs | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 05e0bdd..f17d1ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ The consequence that matters most: **nothing here executes from the repo.** Edit **Run the suite twice when touching state handling** — CI does (`.github/workflows/ci.yml`). The gate's barrier is scoped by a monotonic turn counter rather than wall-clock time. -Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; the cache keeps the stripped module until its content changes. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. +Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` where it exists (Node 22.8+, through a namespace import so 22.6 still links) and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; the cache keeps the stripped module until its content changes. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. Two harness details are load-bearing rather than incidental: diff --git a/hooks/kb-gate.mjs b/hooks/kb-gate.mjs index 59eef4d..468b083 100644 --- a/hooks/kb-gate.mjs +++ b/hooks/kb-gate.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning -import { enableCompileCache } from "node:module"; +import * as nodeModule from "node:module"; // Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so // this launcher stays .mjs and the cache keeps the stripped module until it changes. -enableCompileCache(); +// enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. +nodeModule.enableCompileCache?.(); await import("./kb-gate.mts"); diff --git a/hooks/sync-memories.mjs b/hooks/sync-memories.mjs index 2491d57..b602eb2 100644 --- a/hooks/sync-memories.mjs +++ b/hooks/sync-memories.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning -import { enableCompileCache } from "node:module"; +import * as nodeModule from "node:module"; // Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so // this launcher stays .mjs and the cache keeps the stripped module until it changes. -enableCompileCache(); +// enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. +nodeModule.enableCompileCache?.(); await import("./sync-memories.mts"); From 98fef318a782971f67d03ab34a1de23fba1fc1d5 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 1 Nov 2026 00:36:37 +0100 Subject: [PATCH 3/3] State that the compile cache skips type stripping only from Node 24 - Launcher comments and CLAUDE.md: Node 22 caches only the compile step --- CLAUDE.md | 2 +- hooks/kb-gate.mjs | 2 +- hooks/sync-memories.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f17d1ca..8db4f25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ The consequence that matters most: **nothing here executes from the repo.** Edit **Run the suite twice when touching state handling** — CI does (`.github/workflows/ci.yml`). The gate's barrier is scoped by a monotonic turn counter rather than wall-clock time. -Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` where it exists (Node 22.8+, through a namespace import so 22.6 still links) and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; the cache keeps the stripped module until its content changes. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. +Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` where it exists (Node 22.8+, through a namespace import so 22.6 still links) and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; from Node 24 the cache keeps the stripped module until its content changes, while on Node 22 it saves only the compile step. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. Two harness details are load-bearing rather than incidental: diff --git a/hooks/kb-gate.mjs b/hooks/kb-gate.mjs index 468b083..912c9c1 100644 --- a/hooks/kb-gate.mjs +++ b/hooks/kb-gate.mjs @@ -2,7 +2,7 @@ import * as nodeModule from "node:module"; // Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so -// this launcher stays .mjs and the cache keeps the stripped module until it changes. +// this launcher stays .mjs. From Node 24 the cache also skips the stripping; on Node 22 it saves only the compile. // enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. nodeModule.enableCompileCache?.(); await import("./kb-gate.mts"); diff --git a/hooks/sync-memories.mjs b/hooks/sync-memories.mjs index b602eb2..a106400 100644 --- a/hooks/sync-memories.mjs +++ b/hooks/sync-memories.mjs @@ -2,7 +2,7 @@ import * as nodeModule from "node:module"; // Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so -// this launcher stays .mjs and the cache keeps the stripped module until it changes. +// this launcher stays .mjs. From Node 24 the cache also skips the stripping; on Node 22 it saves only the compile. // enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. nodeModule.enableCompileCache?.(); await import("./sync-memories.mts");