@vkmikc/create-vkm-kit 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +44 -0
- package/README.md +148 -0
- package/package.json +57 -0
- package/src/asset-install.mjs +109 -0
- package/src/claude-native-memory.mjs +507 -0
- package/src/file-perms.mjs +53 -0
- package/src/hooks/_transcript-cache.mjs +223 -0
- package/src/hooks/compact-mcp-output.mjs +87 -0
- package/src/hooks/compact-tool-output.mjs +177 -0
- package/src/hooks/ensure-otel-sink.mjs +51 -0
- package/src/hooks/guard-effort-gate.mjs +209 -0
- package/src/hooks/guard-native-memory-write.mjs +129 -0
- package/src/hooks/session-start-vault-context.mjs +200 -0
- package/src/hooks/stop-vault-close-reminder.mjs +150 -0
- package/src/index.js +1547 -0
- package/src/mcp-merge.mjs +279 -0
- package/src/memory-rules.mjs +205 -0
- package/src/obscura-setup.mjs +272 -0
- package/src/ollama-setup.mjs +126 -0
- package/src/rules-merge.mjs +106 -0
- package/src/settings-io.mjs +193 -0
- package/src/settings-writers.mjs +150 -0
- package/src/skills-install.mjs +96 -0
- package/src/telemetry.mjs +154 -0
- package/src/token-saver.mjs +248 -0
- package/templates/agents/vkm-implementer.md +23 -0
- package/templates/output-styles/vkm-terse.md +23 -0
- package/templates/skills/vkm-discipline/SKILL.md +77 -0
- package/templates/skills/vkm-discipline/domains/coding.md +39 -0
- package/templates/skills/vkm-discipline/domains/data.md +37 -0
- package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
- package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
- package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
- package/templates/skills/vkm-discipline/domains/infra.md +35 -0
- package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
- package/templates/skills/vkm-discipline/domains/security.md +37 -0
- package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
- package/templates/skills/vkm-discipline/domains/writing.md +32 -0
- package/templates/skills/vkm-spec/SKILL.md +33 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code `PreToolUse` hook — deterministically DENIES a session's 2nd+ substantive
|
|
4
|
+
* Write/Edit/MultiEdit/NotebookEdit call until the model has proposed an effort level
|
|
5
|
+
* (`/effort low|medium|high|xhigh|max`) AND gotten a real reply from the user.
|
|
6
|
+
*
|
|
7
|
+
* Why a hook and not just a CLAUDE.md sentence: a model that announces "pausing for your
|
|
8
|
+
* confirmation" and then immediately keeps calling tools in the same turn is a common
|
|
9
|
+
* failure mode — the announcement is prose, and prose doesn't stop a tool call. Same root
|
|
10
|
+
* cause ADR-0029/ADR-0030 already named: relying on the model to *honor* a rule against an
|
|
11
|
+
* always-available tool is exactly what fails in practice. This hook makes the pause real
|
|
12
|
+
* by denying the call until a genuine user turn happened after the proposal. See ADR-0031.
|
|
13
|
+
*
|
|
14
|
+
* Anti-nagging, mirroring the ADR-0030 `Stop` hook's threshold: the FIRST substantive call
|
|
15
|
+
* of a session is always free (a one-line fix shouldn't need a ritual). Gating starts at
|
|
16
|
+
* the 2nd. Once the gate is satisfied once, it stays open for the rest of the session — this
|
|
17
|
+
* is a one-time checkpoint per session, not a per-task interrogation.
|
|
18
|
+
*
|
|
19
|
+
* Main agent only — never a sub-agent (ADR-0031 addendum): a Task-tool sub-agent (a fanned-out
|
|
20
|
+
* `vkm-implementer`, `Explore`, etc.) cannot ever satisfy this gate — a coordinator's relayed
|
|
21
|
+
* `SendMessage` confirmation and an `AskUserQuestion` answer both land in the transcript as
|
|
22
|
+
* content `isRealUserTurn()` deliberately excludes (see its own doc comment), so a sub-agent
|
|
23
|
+
* would be denied forever, wedging any multi-agent workflow. Claude Code populates `agent_id`
|
|
24
|
+
* on the hook's stdin payload only when the hook fires inside a sub-agent — its presence is
|
|
25
|
+
* the signal to defer immediately, no scan needed. The gate still applies in full to the main
|
|
26
|
+
* agent's own direct edits; this makes it neutral toward delegation, neither encouraging nor
|
|
27
|
+
* blocking it, rather than an obstacle a workflow has to route around.
|
|
28
|
+
*
|
|
29
|
+
* Installed by create-obsidian-memory — the vkm-kit installer — into `~/.claude/hooks/` next
|
|
30
|
+
* to the other managed hooks, registered in `~/.claude/settings.json` as:
|
|
31
|
+
* node "<this file>" [lang]
|
|
32
|
+
* with matcher `"Write|Edit|MultiEdit|NotebookEdit"`. Reads `transcript_path` from the
|
|
33
|
+
* hook's stdin payload (like the `Stop` hook), not from argv — there's no file path to
|
|
34
|
+
* check here, just conversation history.
|
|
35
|
+
*
|
|
36
|
+
* Contract (Claude Code `PreToolUse` hooks):
|
|
37
|
+
* - Read the hook's JSON payload from stdin (`tool_name`, `transcript_path`, `agent_id`, …
|
|
38
|
+
* — `agent_id` is populated only inside a sub-agent call, see the main-agent-only note above).
|
|
39
|
+
* - To deny the call, print ONE JSON object to stdout:
|
|
40
|
+
* { "hookSpecificOutput": { "hookEventName": "PreToolUse",
|
|
41
|
+
* "permissionDecision": "deny", "permissionDecisionReason": "<text>" } }
|
|
42
|
+
* - To defer to the normal permission flow, print nothing and exit 0.
|
|
43
|
+
* - Never throw: a malformed payload or unreadable transcript must fall through to the
|
|
44
|
+
* normal permission flow (fail OPEN — this hook must never hang or break a session).
|
|
45
|
+
*
|
|
46
|
+
* Performance: re-parsing the WHOLE transcript on every gated call gets slower as a session
|
|
47
|
+
* grows (measured ~75ms on a real 25.8MB transcript) — see `_transcript-cache.mjs` (installed
|
|
48
|
+
* alongside this file) for the sidecar-cache fix that makes each call only read the NEW
|
|
49
|
+
* suffix appended since the last one. Purely a performance change: `scanTranscript` below
|
|
50
|
+
* returns the identical shape/values a full rescan always did.
|
|
51
|
+
*/
|
|
52
|
+
import fs from "node:fs";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import { getIncrementalState } from "./_transcript-cache.mjs";
|
|
56
|
+
|
|
57
|
+
const SUBSTANTIVE_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
58
|
+
/** Below this many PRIOR substantive calls, the gate stays out of the way entirely. */
|
|
59
|
+
const MIN_SUBSTANTIVE_CALLS = 2;
|
|
60
|
+
/** Matches ONLY the literal marker the model is told to print — see reason() below. */
|
|
61
|
+
const MARKER_RE = /\[!\]\s*(recomendaci[oó]n de esfuerzo|effort recommendation)/i;
|
|
62
|
+
|
|
63
|
+
function readStdin() {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(0, "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True if a transcript entry is a REAL user turn — as opposed to a tool result, which
|
|
73
|
+
* Claude Code also encodes as a `type:"user"` message (`content: [{type:"tool_result",…}]`).
|
|
74
|
+
* A turn only counts as a genuine reply if it carries the user's own text.
|
|
75
|
+
*/
|
|
76
|
+
function isRealUserTurn(entry) {
|
|
77
|
+
if (!entry || entry.type !== "user") return false;
|
|
78
|
+
const content = entry?.message?.content;
|
|
79
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
80
|
+
if (!Array.isArray(content)) return false;
|
|
81
|
+
return content.some((block) => block && block.type !== "tool_result");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function initialScanState() {
|
|
85
|
+
return { substantiveBefore: 0, pending: false, satisfied: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fold ONE raw JSONL line into `state` (mutate-and-return). Same per-line logic the old
|
|
90
|
+
* always-full-rescan version used, factored out so it can run over just the transcript's
|
|
91
|
+
* NEW suffix (see `_transcript-cache.mjs`) with an identical result to scanning from byte 0:
|
|
92
|
+
* each line's effect depends only on itself and the accumulator so far (`pending` gates
|
|
93
|
+
* whether a later user turn sets `satisfied`), never on lines further ahead.
|
|
94
|
+
*/
|
|
95
|
+
function foldTranscriptLine(state, line) {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed) return state;
|
|
98
|
+
let entry;
|
|
99
|
+
try {
|
|
100
|
+
entry = JSON.parse(trimmed);
|
|
101
|
+
} catch {
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
if (entry?.type === "assistant") {
|
|
105
|
+
const content = entry?.message?.content;
|
|
106
|
+
if (Array.isArray(content)) {
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
if (!block) continue;
|
|
109
|
+
if (block.type === "text" && typeof block.text === "string" && MARKER_RE.test(block.text)) {
|
|
110
|
+
state.pending = true;
|
|
111
|
+
} else if (block.type === "tool_use" && SUBSTANTIVE_TOOLS.test(block.name || "")) {
|
|
112
|
+
state.substantiveBefore++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else if (state.pending && isRealUserTurn(entry)) {
|
|
117
|
+
state.satisfied = true;
|
|
118
|
+
state.pending = false;
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scan the JSONL transcript in order (walks each assistant turn's content blocks in array
|
|
125
|
+
* order — text marker before any later tool_use in the SAME prior turn still counts, since
|
|
126
|
+
* the call currently being gated is by definition not yet in this file; `satisfied` is
|
|
127
|
+
* monotonic, staying set for the rest of the scan once a real user turn follows a proposal).
|
|
128
|
+
* Computed incrementally via the shared sidecar cache (`_transcript-cache.mjs`) — resumes
|
|
129
|
+
* from the last-consumed byte offset when safe, full rescan otherwise. Never throws; returns
|
|
130
|
+
* the SAME shape a full rescan from byte 0 always did.
|
|
131
|
+
*/
|
|
132
|
+
export function scanTranscript(transcriptPath) {
|
|
133
|
+
return getIncrementalState(transcriptPath, "effort-gate", initialScanState, foldTranscriptLine);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function reason(lang, alreadyProposed) {
|
|
137
|
+
if (lang === "en") {
|
|
138
|
+
return alreadyProposed
|
|
139
|
+
? "Paused: you already proposed an effort level but there's no reply from the user " +
|
|
140
|
+
"yet in this conversation. Do not call any more tools this turn — wait for the " +
|
|
141
|
+
"user's next message before retrying."
|
|
142
|
+
: "Paused: this session is about to make its 2nd+ substantive edit without an effort " +
|
|
143
|
+
"estimate. Before calling any more tools, reply with ONLY this block (no tool " +
|
|
144
|
+
"calls in this turn), then stop and wait for the user's next message:\n\n" +
|
|
145
|
+
"[!] EFFORT RECOMMENDATION\n" +
|
|
146
|
+
"- Task: <short description>\n" +
|
|
147
|
+
"- Suggested level: /effort <low|medium|high|xhigh|max>\n" +
|
|
148
|
+
"- Reason: <why>\n\n" +
|
|
149
|
+
"Retry only after the user replies (confirming or naming a different level). See ADR-0031.";
|
|
150
|
+
}
|
|
151
|
+
return alreadyProposed
|
|
152
|
+
? "Pausa: ya propusiste un nivel de esfuerzo pero todavía no hay respuesta del usuario " +
|
|
153
|
+
"en esta conversación. No llames más herramientas en este turno — esperá el próximo " +
|
|
154
|
+
"mensaje del usuario antes de reintentar."
|
|
155
|
+
: "Pausa: esta sesión va a hacer su 2.º+ edit sustantivo sin haber propuesto un nivel " +
|
|
156
|
+
"de esfuerzo. Antes de llamar más herramientas, respondé SOLO con este bloque (nada " +
|
|
157
|
+
"de tool calls en este turno), después parate y esperá el próximo mensaje del usuario:\n\n" +
|
|
158
|
+
"[!] RECOMENDACIÓN DE ESFUERZO\n" +
|
|
159
|
+
"- Tarea: <descripción breve>\n" +
|
|
160
|
+
"- Nivel sugerido: /effort <low|medium|high|xhigh|max>\n" +
|
|
161
|
+
"- Razón: <por qué>\n\n" +
|
|
162
|
+
"Reintentá solo después de que el usuario responda (confirmando u otro nivel). Ver ADR-0031.";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
const lang = (process.argv[2] || "es").toLowerCase() === "en" ? "en" : "es";
|
|
167
|
+
|
|
168
|
+
let input;
|
|
169
|
+
try {
|
|
170
|
+
input = JSON.parse(readStdin() || "{}");
|
|
171
|
+
} catch {
|
|
172
|
+
return; // unparseable payload — defer to the normal permission flow
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const toolName = typeof input?.tool_name === "string" ? input.tool_name : "";
|
|
176
|
+
if (!SUBSTANTIVE_TOOLS.test(toolName)) return;
|
|
177
|
+
|
|
178
|
+
// Sub-agent call — see the file header for why this can never be satisfied from in here.
|
|
179
|
+
if (typeof input?.agent_id === "string" && input.agent_id) return;
|
|
180
|
+
|
|
181
|
+
const transcriptPath = typeof input?.transcript_path === "string" ? input.transcript_path : "";
|
|
182
|
+
if (!transcriptPath) return; // no transcript to check against — fail open
|
|
183
|
+
|
|
184
|
+
const { substantiveBefore, pending, satisfied } = scanTranscript(transcriptPath);
|
|
185
|
+
if (substantiveBefore === 0) return; // first substantive edit of the session is free
|
|
186
|
+
if (satisfied) return; // gated once already this session — stay out of the way
|
|
187
|
+
|
|
188
|
+
const payload = {
|
|
189
|
+
hookSpecificOutput: {
|
|
190
|
+
hookEventName: "PreToolUse",
|
|
191
|
+
permissionDecision: "deny",
|
|
192
|
+
permissionDecisionReason: reason(lang, pending)
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
process.stdout.write(JSON.stringify(payload));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Only auto-run when executed directly (`node guard-effort-gate.mjs ...`), not when imported
|
|
199
|
+
// — `scanTranscript` is exported above so tests can unit-test the caching behavior directly
|
|
200
|
+
// (call counts, resumed-vs-full-scan byte counts) without a stdin read as an import side effect.
|
|
201
|
+
const isMainModule =
|
|
202
|
+
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
203
|
+
if (isMainModule) {
|
|
204
|
+
try {
|
|
205
|
+
main();
|
|
206
|
+
} catch {
|
|
207
|
+
// Never let a bug in this hook block a legitimate tool call.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code `PreToolUse` hook — deterministically DENIES Write/Edit/MultiEdit/
|
|
4
|
+
* NotebookEdit calls that target Claude's native per-project auto-memory directory
|
|
5
|
+
* (`~/.claude/projects/<encoded-cwd>/memory/`), redirecting the model to the vault tools.
|
|
6
|
+
*
|
|
7
|
+
* Why a hook and not just another CLAUDE.md sentence: ADR-0029 already disables the
|
|
8
|
+
* native auto-memory at the config level (`autoMemoryEnabled:false`) because relying on
|
|
9
|
+
* the model to *honor a rule* against an always-available `Write` tool is exactly what
|
|
10
|
+
* failed in practice on weaker/older models. This hook closes the remaining gap — a model
|
|
11
|
+
* that still tries to `Write` into that directory out of habit (or because it never
|
|
12
|
+
* loaded/read the rules) is blocked by the harness itself, independent of which model is
|
|
13
|
+
* driving. See ADR-0030.
|
|
14
|
+
*
|
|
15
|
+
* Installed by create-obsidian-memory — the vkm-kit installer — into `~/.claude/hooks/` next
|
|
16
|
+
* to the SessionStart hook, registered in `~/.claude/settings.json` as:
|
|
17
|
+
* node "<this file>" "<claudeDir>" [lang]
|
|
18
|
+
* with matcher `"Write|Edit|MultiEdit|NotebookEdit"` so the harness only invokes it for
|
|
19
|
+
* file-mutating tools. The script re-checks `tool_name` itself too, defensively.
|
|
20
|
+
*
|
|
21
|
+
* Contract (Claude Code `PreToolUse` hooks):
|
|
22
|
+
* - Read the hook's JSON payload from stdin (`tool_name`, `tool_input`, …).
|
|
23
|
+
* - To deny the call, print ONE JSON object to stdout:
|
|
24
|
+
* { "hookSpecificOutput": { "hookEventName": "PreToolUse",
|
|
25
|
+
* "permissionDecision": "deny", "permissionDecisionReason": "<text>" } }
|
|
26
|
+
* - To defer to the normal permission flow, print nothing and exit 0.
|
|
27
|
+
* - Never throw: a malformed payload or unreadable input must fall through to the
|
|
28
|
+
* normal permission flow, not break the user's tool call.
|
|
29
|
+
*/
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
const MUTATING_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
35
|
+
|
|
36
|
+
function readStdin() {
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(0, "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True if `filePath` lives under `<claudeDir>/projects/<anything>/memory/...`. Case-folds
|
|
46
|
+
* the comparison ONLY on platforms whose default filesystem is case-insensitive (`win32`) —
|
|
47
|
+
* everywhere else (Linux's default; `darwin`'s default APFS is usually case-insensitive too,
|
|
48
|
+
* but treating it as case-sensitive is the SAFE direction, i.e. never worse than before) an
|
|
49
|
+
* exact-case comparison is used, so a differently-cased-but-distinct directory that merely
|
|
50
|
+
* collides case-insensitively with the native-memory path is never over-matched (which would
|
|
51
|
+
* incorrectly deny a legitimate write on a case-sensitive filesystem).
|
|
52
|
+
* @param {string} filePath
|
|
53
|
+
* @param {string} claudeDir
|
|
54
|
+
* @param {string} [platform] - defaults to `process.platform`; overridable so tests can
|
|
55
|
+
* simulate a non-Windows platform without actually running on one.
|
|
56
|
+
*/
|
|
57
|
+
export function isNativeMemoryPath(filePath, claudeDir, platform = process.platform) {
|
|
58
|
+
if (!filePath || !claudeDir) return false;
|
|
59
|
+
try {
|
|
60
|
+
const caseInsensitive = platform === "win32";
|
|
61
|
+
const fold = (s) => (caseInsensitive ? s.toLowerCase() : s);
|
|
62
|
+
const norm = fold(path.resolve(filePath).replace(/\\/g, "/"));
|
|
63
|
+
const dirNorm = fold(path.resolve(claudeDir).replace(/\\/g, "/"));
|
|
64
|
+
if (!norm.startsWith(`${dirNorm}/`)) return false;
|
|
65
|
+
const rel = norm.slice(dirNorm.length + 1).split("/");
|
|
66
|
+
return rel[0] === "projects" && rel.includes("memory");
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reason(lang) {
|
|
73
|
+
if (lang === "en") {
|
|
74
|
+
return (
|
|
75
|
+
"Blocked: this path is Claude Code's NATIVE auto-memory, disabled by this kit " +
|
|
76
|
+
"(autoMemoryEnabled:false, ADR-0029). Write the close ritual to the Obsidian vault " +
|
|
77
|
+
"instead — mcp__obsidian-memory-hybrid__vault_write_file / vault_edit_file " +
|
|
78
|
+
"(SESSION_LOG.md + PROJECTS/<project>.md), or basic-memory's write_note/edit_note. " +
|
|
79
|
+
"See ADR-0030."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
"Bloqueado: esa ruta es la auto-memoria NATIVA de Claude Code, desactivada por este kit " +
|
|
84
|
+
"(autoMemoryEnabled:false, ADR-0029). Escribe el cierre en el vault Obsidian en su lugar " +
|
|
85
|
+
"— mcp__obsidian-memory-hybrid__vault_write_file / vault_edit_file " +
|
|
86
|
+
"(SESSION_LOG.md + PROJECTS/<proyecto>.md), o write_note/edit_note de basic-memory. " +
|
|
87
|
+
"Ver ADR-0030."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main() {
|
|
92
|
+
const claudeDir = process.argv[2] || "";
|
|
93
|
+
const lang = (process.argv[3] || "es").toLowerCase() === "en" ? "en" : "es";
|
|
94
|
+
|
|
95
|
+
let input;
|
|
96
|
+
try {
|
|
97
|
+
input = JSON.parse(readStdin() || "{}");
|
|
98
|
+
} catch {
|
|
99
|
+
return; // unparseable payload — defer to the normal permission flow
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const toolName = typeof input?.tool_name === "string" ? input.tool_name : "";
|
|
103
|
+
if (!MUTATING_TOOLS.test(toolName)) return;
|
|
104
|
+
|
|
105
|
+
const filePath = input?.tool_input?.file_path || input?.tool_input?.notebook_path || "";
|
|
106
|
+
if (!isNativeMemoryPath(filePath, claudeDir)) return;
|
|
107
|
+
|
|
108
|
+
const payload = {
|
|
109
|
+
hookSpecificOutput: {
|
|
110
|
+
hookEventName: "PreToolUse",
|
|
111
|
+
permissionDecision: "deny",
|
|
112
|
+
permissionDecisionReason: reason(lang)
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
process.stdout.write(JSON.stringify(payload));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only auto-run when executed directly (`node guard-native-memory-write.mjs ...`), not when
|
|
119
|
+
// imported — `isNativeMemoryPath` is exported above so tests can unit-test it directly
|
|
120
|
+
// (e.g. with a platform override) without triggering a stdin read as a side effect of import.
|
|
121
|
+
const isMainModule =
|
|
122
|
+
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
123
|
+
if (isMainModule) {
|
|
124
|
+
try {
|
|
125
|
+
main();
|
|
126
|
+
} catch {
|
|
127
|
+
// Never let a bug in this hook block a legitimate tool call.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code `SessionStart` hook — injects the vault map + the "vault is the only
|
|
4
|
+
* source of truth" reminders as `additionalContext`, so every session starts knowing
|
|
5
|
+
* the native auto-memory is OFF and recall/close go through the Obsidian vault.
|
|
6
|
+
*
|
|
7
|
+
* Installed by create-obsidian-memory — the vkm-kit installer — into `~/.claude/hooks/` and
|
|
8
|
+
* registered in `~/.claude/settings.json` as: `node "<this file>" "<vault>" [lang]`.
|
|
9
|
+
*
|
|
10
|
+
* Cross-platform on purpose (Node, not PowerShell/bash) so ONE script — and ONE copy
|
|
11
|
+
* of the reminder text — serves Windows, macOS and Linux.
|
|
12
|
+
*
|
|
13
|
+
* Contract (Claude Code hooks):
|
|
14
|
+
* - Print a single JSON object to stdout.
|
|
15
|
+
* - To inject text into the session context, emit:
|
|
16
|
+
* { "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "<text>" } }
|
|
17
|
+
* - A non-zero exit is logged but does NOT abort the session. We never throw: a broken
|
|
18
|
+
* vault path must not block the user, so on any error we still emit the reminders.
|
|
19
|
+
*/
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
/** Vault path: 1st CLI arg wins, else the MCP env vars the kit sets. */
|
|
25
|
+
function resolveVault() {
|
|
26
|
+
const fromArg = process.argv[2];
|
|
27
|
+
if (fromArg && fromArg.trim()) return fromArg.trim();
|
|
28
|
+
return (
|
|
29
|
+
process.env.VKM_VAULT ||
|
|
30
|
+
process.env.BASIC_MEMORY_HOME ||
|
|
31
|
+
process.env.OBSIDIAN_MEMORY_VAULT ||
|
|
32
|
+
""
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripBom(text) {
|
|
37
|
+
return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Backstop cap on the curated index dump injected into EVERY session. A
|
|
41
|
+
* hand-maintained index that gains one entry per project only grows over time — left
|
|
42
|
+
* uncapped, this hook's fixed per-session token tax grows right along with the vault
|
|
43
|
+
* (~10KB / ~2500 tokens on a modest 69-note vault). Compression (below) runs first;
|
|
44
|
+
* this cap only bites on unusually large or unusually prosy indices. */
|
|
45
|
+
export const MAX_INDEX_CHARS = 3000;
|
|
46
|
+
|
|
47
|
+
/** Per-entry cap for compressIndex: enough for "name — one-line purpose", not for
|
|
48
|
+
* the whole multi-sentence history an index entry tends to accrete. */
|
|
49
|
+
export const MAX_ENTRY_CHARS = 100;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compress the curated index instead of blindly truncating it (ADR-0035). The old
|
|
53
|
+
* hard cut kept the first N chars — full prose for early entries, and any project
|
|
54
|
+
* past the cap silently invisible to the session. For a *pointer map* that's
|
|
55
|
+
* backwards: every entry's NAME is worth more than any entry's third sentence.
|
|
56
|
+
* So: keep headings and structure, cap each list item at MAX_ENTRY_CHARS (cut at a
|
|
57
|
+
* word boundary, '…'), drop YAML frontmatter and collapse blank runs. Result: all
|
|
58
|
+
* pointers visible, prose available on demand via vault_read_file/search.
|
|
59
|
+
*/
|
|
60
|
+
export function compressIndex(index) {
|
|
61
|
+
const lines = index.split(/\r?\n/);
|
|
62
|
+
let i = 0;
|
|
63
|
+
// Drop a leading YAML frontmatter block (--- … ---): metadata, not pointers.
|
|
64
|
+
if (lines[0]?.trim() === "---") {
|
|
65
|
+
const end = lines.findIndex((l, j) => j > 0 && l.trim() === "---");
|
|
66
|
+
if (end > 0) i = end + 1;
|
|
67
|
+
}
|
|
68
|
+
const out = [];
|
|
69
|
+
let blank = false;
|
|
70
|
+
for (; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i].trimEnd();
|
|
72
|
+
if (!line.trim()) {
|
|
73
|
+
if (!blank && out.length) out.push("");
|
|
74
|
+
blank = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const isHeading = line.startsWith("#");
|
|
78
|
+
const isBullet = line.trimStart().startsWith("- ");
|
|
79
|
+
let kept = line;
|
|
80
|
+
if (!isHeading && line.trim().length > MAX_ENTRY_CHARS) {
|
|
81
|
+
if (!isBullet) continue; // over-cap prose (incl. bullet overflow wraps): a
|
|
82
|
+
// mid-sentence fragment reads worse than absence — the full text is one
|
|
83
|
+
// vault_read_file away. Bullets ARE the pointers, so those get capped instead:
|
|
84
|
+
const slice = line.slice(0, MAX_ENTRY_CHARS);
|
|
85
|
+
const cutAt = slice.lastIndexOf(" ");
|
|
86
|
+
kept = slice.slice(0, cutAt > MAX_ENTRY_CHARS / 2 ? cutAt : MAX_ENTRY_CHARS) + "…";
|
|
87
|
+
}
|
|
88
|
+
blank = false;
|
|
89
|
+
out.push(kept);
|
|
90
|
+
}
|
|
91
|
+
// A dropped prose block can leave a dangling blank at the tail.
|
|
92
|
+
while (out.length && !out[out.length - 1]) out.pop();
|
|
93
|
+
return out.join("\n").trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function truncateIndex(index, lang) {
|
|
97
|
+
const compressed = compressIndex(index);
|
|
98
|
+
if (compressed.length <= MAX_INDEX_CHARS) return compressed;
|
|
99
|
+
const notice =
|
|
100
|
+
lang === "en"
|
|
101
|
+
? `\n\n[...truncated: index is ${compressed.length} chars compressed, showing the first ${MAX_INDEX_CHARS}. Use vault_read_file("_meta/index.md") for the rest.]`
|
|
102
|
+
: `\n\n[...truncado: el indice comprimido tiene ${compressed.length} caracteres, mostrando los primeros ${MAX_INDEX_CHARS}. Usa vault_read_file("_meta/index.md") para el resto.]`;
|
|
103
|
+
return compressed.slice(0, MAX_INDEX_CHARS) + notice;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The reinforced precedence reminders. Kept in sync with the CLAUDE.md rules block. */
|
|
107
|
+
export function reminders(lang) {
|
|
108
|
+
if (lang === "en") {
|
|
109
|
+
return [
|
|
110
|
+
"---",
|
|
111
|
+
"MEMORY — precedence (HARD RULE): the Obsidian vault is the ONLY source of truth. Claude Code's NATIVE auto-memory (~/.claude/projects/*/memory/, the system prompt's \"# Memory\" section) is DISABLED by the installer (autoMemoryEnabled:false) / is a READ-ONLY MIRROR: do NOT write the close ritual there, redirect to the vault.",
|
|
112
|
+
"- FIRST STEP if the vault_* tools appear as deferred (common with many MCP servers connected): load them with ToolSearch (select:vault_hybrid_search,vault_read_file,vault_edit_file,vault_write_file) BEFORE touching memory. The native Write tool is zero-friction and tempting; resist it.",
|
|
113
|
+
'- For ANY non-trivial task (or when a project/person/decision/tool is named): BEFORE answering call mcp__obsidian-memory-hybrid__vault_hybrid_search("<topic>") and answer from the returned section (passage-first, cheap). Use read_text_file/vault_read_file only if you need the whole file.',
|
|
114
|
+
"- If it's the first task of the session, start with START_HERE.md and MEMORY.md.",
|
|
115
|
+
'- CLOSE (reusable task done): write to the vault with vault_edit_file/vault_write_file -> SESSION_LOG.md (1 line at the end) + PROJECTS/<project>.md (incremental, above "## Related") + STACKS/PRACTICES if it applies. Anchor each vault_edit_file on ONE single line (notes are CRLF). Don\'t commit: the obsidian-memoryd daemon syncs.'
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
"---",
|
|
120
|
+
'MEMORIA — precedencia (REGLA DURA): el vault Obsidian es la UNICA fuente de verdad. La auto-memoria nativa de Claude Code (~/.claude/projects/*/memory/, la seccion "# Memory" del system prompt) esta DESACTIVADA por el instalador (autoMemoryEnabled:false) / es ESPEJO READ-ONLY: NO escribas el cierre ahi, redirigi al vault.',
|
|
121
|
+
"- PRIMER PASO si las tools vault_* aparecen como deferred (frecuente con muchos MCP conectados): cargalas con ToolSearch (select:vault_hybrid_search,vault_read_file,vault_edit_file,vault_write_file) ANTES de tocar memoria. El Write nativo es cero-friccion y tienta; resistilo.",
|
|
122
|
+
'- Para CUALQUIER tarea no trivial (o si mencionas un proyecto/persona/decision/herramienta): ANTES de responder llama mcp__obsidian-memory-hybrid__vault_hybrid_search("<tema>") y responde con la seccion que devuelve (passage-first, barato). Usa read_text_file/vault_read_file solo si necesitas el archivo entero.',
|
|
123
|
+
"- Si es la primera tarea de la sesion, empieza por START_HERE.md y MEMORY.md.",
|
|
124
|
+
'- CIERRE (tarea reusable terminada): escribi al vault con vault_edit_file/vault_write_file -> SESSION_LOG.md (1 linea al final) + PROJECTS/<proyecto>.md (incremental, arriba de "## Relacionado") + STACKS/PRACTICES si aplica. Ancla cada vault_edit_file en UNA sola linea (notas en CRLF). No commitees: el daemon obsidian-memoryd sincroniza.'
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildContext(vault, lang) {
|
|
129
|
+
const header =
|
|
130
|
+
lang === "en"
|
|
131
|
+
? "## Obsidian vault available (MCP memory)"
|
|
132
|
+
: "## Vault Obsidian disponible (memoria MCP)";
|
|
133
|
+
const parts = [header, ""];
|
|
134
|
+
if (vault) parts.push(`Path: ${vault}`, "");
|
|
135
|
+
|
|
136
|
+
// Top-level folder listing — so the agent knows the structure even if the index is stale.
|
|
137
|
+
if (vault) {
|
|
138
|
+
try {
|
|
139
|
+
const entries = fs
|
|
140
|
+
.readdirSync(vault, { withFileTypes: true })
|
|
141
|
+
.filter((e) => e.isDirectory() && !/^(\.git|\.obsidian)$/.test(e.name))
|
|
142
|
+
.map((e) => `- ${e.name}/`);
|
|
143
|
+
if (entries.length) {
|
|
144
|
+
parts.push(
|
|
145
|
+
lang === "en" ? "Top-level folders:" : "Carpetas top-level:",
|
|
146
|
+
entries.join("\n"),
|
|
147
|
+
""
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
/* vault unreadable — still emit the reminders below */
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Curated index (_meta/index.md), if present — capped (see truncateIndex).
|
|
155
|
+
try {
|
|
156
|
+
const indexPath = path.join(vault, "_meta", "index.md");
|
|
157
|
+
if (fs.existsSync(indexPath)) {
|
|
158
|
+
const index = truncateIndex(stripBom(fs.readFileSync(indexPath, "utf8")), lang);
|
|
159
|
+
// A pathological index (all over-cap prose, no pointers) compresses to "" —
|
|
160
|
+
// emit nothing rather than an empty labelled section.
|
|
161
|
+
if (index) {
|
|
162
|
+
parts.push(
|
|
163
|
+
lang === "en" ? "Curated index (_meta/index.md):" : "Indice curado (_meta/index.md):",
|
|
164
|
+
"",
|
|
165
|
+
index,
|
|
166
|
+
""
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
/* index unreadable — still emit the reminders below */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
parts.push(reminders(lang));
|
|
176
|
+
return parts.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function main() {
|
|
180
|
+
const vault = resolveVault();
|
|
181
|
+
const lang = (process.argv[3] || "es").toLowerCase() === "en" ? "en" : "es";
|
|
182
|
+
let additionalContext;
|
|
183
|
+
try {
|
|
184
|
+
additionalContext = buildContext(vault, lang);
|
|
185
|
+
} catch {
|
|
186
|
+
additionalContext = reminders(lang); // last-resort: never block the session
|
|
187
|
+
}
|
|
188
|
+
const payload = {
|
|
189
|
+
hookSpecificOutput: {
|
|
190
|
+
hookEventName: "SessionStart",
|
|
191
|
+
additionalContext
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
process.stdout.write(JSON.stringify(payload));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Guard so importing this module (e.g. from a test) does not also run main() as a
|
|
198
|
+
// side effect — mirrors the pattern in hybrid-mcp.mjs / the other managed hooks.
|
|
199
|
+
const isEntryPoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
200
|
+
if (isEntryPoint) main();
|