@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.
Files changed (39) hide show
  1. package/LICENSE.md +44 -0
  2. package/README.md +148 -0
  3. package/package.json +57 -0
  4. package/src/asset-install.mjs +109 -0
  5. package/src/claude-native-memory.mjs +507 -0
  6. package/src/file-perms.mjs +53 -0
  7. package/src/hooks/_transcript-cache.mjs +223 -0
  8. package/src/hooks/compact-mcp-output.mjs +87 -0
  9. package/src/hooks/compact-tool-output.mjs +177 -0
  10. package/src/hooks/ensure-otel-sink.mjs +51 -0
  11. package/src/hooks/guard-effort-gate.mjs +209 -0
  12. package/src/hooks/guard-native-memory-write.mjs +129 -0
  13. package/src/hooks/session-start-vault-context.mjs +200 -0
  14. package/src/hooks/stop-vault-close-reminder.mjs +150 -0
  15. package/src/index.js +1547 -0
  16. package/src/mcp-merge.mjs +279 -0
  17. package/src/memory-rules.mjs +205 -0
  18. package/src/obscura-setup.mjs +272 -0
  19. package/src/ollama-setup.mjs +126 -0
  20. package/src/rules-merge.mjs +106 -0
  21. package/src/settings-io.mjs +193 -0
  22. package/src/settings-writers.mjs +150 -0
  23. package/src/skills-install.mjs +96 -0
  24. package/src/telemetry.mjs +154 -0
  25. package/src/token-saver.mjs +248 -0
  26. package/templates/agents/vkm-implementer.md +23 -0
  27. package/templates/output-styles/vkm-terse.md +23 -0
  28. package/templates/skills/vkm-discipline/SKILL.md +77 -0
  29. package/templates/skills/vkm-discipline/domains/coding.md +39 -0
  30. package/templates/skills/vkm-discipline/domains/data.md +37 -0
  31. package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
  32. package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
  33. package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
  34. package/templates/skills/vkm-discipline/domains/infra.md +35 -0
  35. package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
  36. package/templates/skills/vkm-discipline/domains/security.md +37 -0
  37. package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
  38. package/templates/skills/vkm-discipline/domains/writing.md +32 -0
  39. package/templates/skills/vkm-spec/SKILL.md +33 -0
@@ -0,0 +1,507 @@
1
+ // Make the Obsidian vault Claude Code's ONLY memory: turn OFF the native per-project
2
+ // auto-memory (`autoMemoryEnabled:false`) and install a `SessionStart` hook that injects
3
+ // the vault map + the "vault is the source of truth" reminders.
4
+ //
5
+ // Why: Claude Code ships a native auto-memory (`~/.claude/projects/<enc>/memory/MEMORY.md`)
6
+ // that the harness auto-loads and the base system prompt tells the model to WRITE with the
7
+ // `Write` tool. It competes with the vault and the native side wins by default (it's in the
8
+ // base prompt with `Write` always available, while the `vault_*` MCP tools are often
9
+ // deferred). Disabling it + a reinforced SessionStart reminder makes the vault win out of
10
+ // the box. See ADR-0029.
11
+ //
12
+ // Idempotent: merges `~/.claude/settings.json` without clobbering other keys or hooks, and
13
+ // REPLACES (never duplicates) our managed `SessionStart` entry — re-runs and an older
14
+ // PowerShell variant of the hook are both recognized by the shared filename stem.
15
+ //
16
+ // ADR-0030 adds two more managed hooks (on by default, `enforce` opt-out) so the doctrine
17
+ // holds even when the driving model doesn't reliably honor a prose rule: a `PreToolUse`
18
+ // guard that DENIES Write/Edit/MultiEdit/NotebookEdit into the native auto-memory directory,
19
+ // and a `Stop` nudge that reminds the close ritual once per turn when the session did
20
+ // substantive file work but never touched the vault.
21
+ //
22
+ // ADR-0031 adds a third, independently-toggleable managed hook (on by default, `effortGate`
23
+ // opt-out): a `PreToolUse` gate that DENIES a session's 2nd+ substantive edit until the model
24
+ // proposed an effort level and got a real reply from the user — same "deterministic beats a
25
+ // prose rule" reasoning, aimed at a different failure mode (the model announcing a pause and
26
+ // not actually taking one).
27
+ //
28
+ // Install/remove is SYMMETRIC (audit finding, 2026-07): every call to
29
+ // `configureClaudeNativeMemory` fully reconciles the desired state — pieces that are wanted
30
+ // get merged in, pieces that are NOT wanted get actively stripped out (not just skipped),
31
+ // so a `--no-effort-gate` re-run after a prior install with the effort gate on actually
32
+ // removes that hook entry instead of leaving it orphaned. `uninstallClaudeNativeMemory`
33
+ // builds on the same removal path plus deletes the hook script files themselves, but only
34
+ // when a marker check proves this kit wrote them (never a user's own same-named file).
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import fse from "fs-extra";
38
+ import pc from "picocolors";
39
+ import {
40
+ atomicWriteJson,
41
+ backupRestricted,
42
+ hasManagedHook,
43
+ isKitOwnedFile,
44
+ mergeManagedHook,
45
+ readSettingsSafe,
46
+ removeManagedHook,
47
+ setOrDeleteHooks
48
+ } from "./settings-io.mjs";
49
+
50
+ /** Shared stem so re-runs (and a legacy `.ps1` install) match our managed entry. */
51
+ export const HOOK_STEM = "session-start-vault-context";
52
+ export const HOOK_BASENAME = `${HOOK_STEM}.mjs`;
53
+
54
+ /** PreToolUse guard (ADR-0030): denies Write/Edit/MultiEdit/NotebookEdit into the native
55
+ * auto-memory directory, independent of whether the model honored the CLAUDE.md rule. */
56
+ export const GUARD_HOOK_STEM = "guard-native-memory-write";
57
+ export const GUARD_HOOK_BASENAME = `${GUARD_HOOK_STEM}.mjs`;
58
+
59
+ /** Stop nudge (ADR-0030): reminds the close ritual once per turn when the session did
60
+ * substantive file work but never touched the vault. */
61
+ export const STOP_HOOK_STEM = "stop-vault-close-reminder";
62
+ export const STOP_HOOK_BASENAME = `${STOP_HOOK_STEM}.mjs`;
63
+
64
+ /** Effort gate (ADR-0031): denies a session's 2nd+ substantive edit until the model
65
+ * proposed an effort level and got a real reply from the user. Independently toggleable
66
+ * from the `enforce` pair above — a user may want one without the other. */
67
+ export const EFFORT_GATE_HOOK_STEM = "guard-effort-gate";
68
+ export const EFFORT_GATE_HOOK_BASENAME = `${EFFORT_GATE_HOOK_STEM}.mjs`;
69
+
70
+ /** Shared sidecar-cache engine (item 3 / perf fix) the Stop nudge and effort-gate hooks both
71
+ * import as a sibling module — not a hook Claude Code invokes itself, but installed and
72
+ * removed alongside whichever of the two needs it (see `configureClaudeNativeMemory`). */
73
+ export const TRANSCRIPT_CACHE_BASENAME = "_transcript-cache.mjs";
74
+
75
+ /** All filename stems this kit manages in `~/.claude/settings.json` hooks. Used both to
76
+ * detect "is there anything of ours to remove" and by `--uninstall`'s file-marker check. */
77
+ const ALL_HOOK_STEMS = [HOOK_STEM, GUARD_HOOK_STEM, STOP_HOOK_STEM, EFFORT_GATE_HOOK_STEM];
78
+
79
+ /** Source hook shipped inside the published package (`src/hooks/`). */
80
+ function packagedHookPath(basename = HOOK_BASENAME) {
81
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "hooks", basename);
82
+ }
83
+
84
+ /**
85
+ * The `SessionStart` hook entry: exec form (`command` + `args`, no shell involved), so a
86
+ * vault path containing a quote, a backtick, or a trailing backslash is passed through as
87
+ * one literal argv element — nothing to escape, nothing to get wrong. (Prior versions built
88
+ * a single interpolated command STRING here — `node "<hook>" "<vault>" <lang>` — which broke
89
+ * on exactly those inputs, e.g. a trailing backslash before the closing quote swallowed it.
90
+ * Claude Code's hook schema supports this exec form for any `command` that resolves to a
91
+ * real executable, which `node` always does, cross-platform.)
92
+ * @param {string} hookPath
93
+ * @param {string} vaultAbs
94
+ * @param {string} [lang]
95
+ * @returns {{ command: string, args: string[] }}
96
+ */
97
+ export function hookCommand(hookPath, vaultAbs, lang = "es") {
98
+ return { command: "node", args: [hookPath, vaultAbs, lang === "en" ? "en" : "es"] };
99
+ }
100
+
101
+ /** The `PreToolUse` guard hook entry: `node "<hook>" "<claudeDir>" <lang>` (exec form). */
102
+ export function guardHookCommand(hookPath, claudeDir, lang = "es") {
103
+ return { command: "node", args: [hookPath, claudeDir, lang === "en" ? "en" : "es"] };
104
+ }
105
+
106
+ /** The `Stop` nudge hook entry (exec form; no path arg — it reads the transcript path
107
+ * Claude Code passes on stdin). */
108
+ export function stopHookCommand(hookPath, lang = "es") {
109
+ return { command: "node", args: [hookPath, lang === "en" ? "en" : "es"] };
110
+ }
111
+
112
+ /** The effort-gate hook entry (exec form; same shape as the Stop nudge — it also reads
113
+ * `transcript_path` from stdin, not argv). */
114
+ export function effortGateHookCommand(hookPath, lang = "es") {
115
+ return { command: "node", args: [hookPath, lang === "en" ? "en" : "es"] };
116
+ }
117
+
118
+ // The shared merge/remove/read/write primitives (hookEntryMatchesStem, mergeManagedHook,
119
+ // removeManagedHook, setOrDeleteHooks, hasManagedHook, readSettingsSafe, backupRestricted,
120
+ // atomicWriteJson, isKitOwnedFile, KIT_FILE_MARKER) live in `settings-io.mjs` — extracted
121
+ // there (Phase 0 groundwork) so other installer modules reuse the same idiom.
122
+
123
+ /**
124
+ * Pure merge: return a NEW settings object with the native auto-memory disabled and our
125
+ * managed `SessionStart` hook present exactly once. Preserves every other key, every other
126
+ * hook event, and every unrelated `SessionStart` entry.
127
+ * @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
128
+ * @param {{ command: string, args: string[] }} hookEntry - from {@link hookCommand}
129
+ * @returns {Record<string, unknown>}
130
+ */
131
+ export function mergeClaudeSettings(existing, hookEntry) {
132
+ const settings =
133
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
134
+ // The whole point of the override: force it off (documented key; default is true).
135
+ settings.autoMemoryEnabled = false;
136
+
137
+ const hooks =
138
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
139
+ ? settings.hooks
140
+ : {};
141
+ settings.hooks = mergeManagedHook(hooks, "SessionStart", "*", hookEntry, HOOK_STEM);
142
+ return settings;
143
+ }
144
+
145
+ /**
146
+ * The removal counterpart to {@link mergeClaudeSettings}: drop the managed `SessionStart`
147
+ * entry, and drop the `autoMemoryEnabled` override too — but ONLY when it's still exactly
148
+ * `false`, i.e. the value this kit itself would have set. If the user (or something else)
149
+ * has since set it to `true`, that's left alone rather than silently re-toggled; we only
150
+ * ever reverse a value we can prove is ours.
151
+ * @param {unknown} existing
152
+ * @returns {Record<string, unknown>}
153
+ */
154
+ export function removeSessionStartHook(existing) {
155
+ const settings =
156
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
157
+ if (settings.autoMemoryEnabled === false) {
158
+ delete settings.autoMemoryEnabled;
159
+ }
160
+ const hadHooks =
161
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
162
+ const hooks = hadHooks ? settings.hooks : {};
163
+ return setOrDeleteHooks(
164
+ settings,
165
+ Boolean(hadHooks),
166
+ removeManagedHook(hooks, "SessionStart", HOOK_STEM)
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Pure merge (ADR-0030): add the deterministic enforcement hooks — a `PreToolUse` guard
172
+ * that denies writes into the native auto-memory directory, and a `Stop` nudge that
173
+ * reminds the close ritual once per turn. Either command may be omitted to install just
174
+ * one. Idempotent and dedup'd the same way as {@link mergeClaudeSettings}.
175
+ * @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
176
+ * @param {{ guardCommand?: {command:string,args:string[]}|null, stopCommand?: {command:string,args:string[]}|null }} commands
177
+ * @returns {Record<string, unknown>}
178
+ */
179
+ export function mergeEnforcementHooks(existing, { guardCommand, stopCommand } = {}) {
180
+ const settings =
181
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
182
+ let hooks =
183
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
184
+ ? settings.hooks
185
+ : {};
186
+ if (guardCommand) {
187
+ hooks = mergeManagedHook(
188
+ hooks,
189
+ "PreToolUse",
190
+ "Write|Edit|MultiEdit|NotebookEdit",
191
+ guardCommand,
192
+ GUARD_HOOK_STEM
193
+ );
194
+ }
195
+ if (stopCommand) {
196
+ hooks = mergeManagedHook(hooks, "Stop", "*", stopCommand, STOP_HOOK_STEM);
197
+ }
198
+ settings.hooks = hooks;
199
+ return settings;
200
+ }
201
+
202
+ /**
203
+ * The removal counterpart to {@link mergeEnforcementHooks}: strip both the guard and stop
204
+ * entries unconditionally (a no-op for whichever, or both, aren't present).
205
+ * @param {unknown} existing
206
+ * @returns {Record<string, unknown>}
207
+ */
208
+ export function removeEnforcementHooks(existing) {
209
+ const settings =
210
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
211
+ const hadHooks =
212
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
213
+ let hooks = hadHooks ? settings.hooks : {};
214
+ hooks = removeManagedHook(hooks, "PreToolUse", GUARD_HOOK_STEM);
215
+ hooks = removeManagedHook(hooks, "Stop", STOP_HOOK_STEM);
216
+ return setOrDeleteHooks(settings, Boolean(hadHooks), hooks);
217
+ }
218
+
219
+ /**
220
+ * Pure merge (ADR-0031): add the effort-gate `PreToolUse` hook. Kept as its OWN function
221
+ * (not folded into {@link mergeEnforcementHooks}) so it's independently toggleable — same
222
+ * reasoning ADR-0030 already used to keep the native-memory override separate from its own
223
+ * hooks. Registers under the same matcher as the native-memory guard; the two coexist as
224
+ * separate `hooks.PreToolUse` entries (deduped by stem, not by matcher) and either may deny
225
+ * independently. Idempotent and dedup'd the same way as {@link mergeClaudeSettings}.
226
+ * @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
227
+ * @param {{ effortGateCommand?: {command:string,args:string[]}|null }} commands
228
+ * @returns {Record<string, unknown>}
229
+ */
230
+ export function mergeEffortGateHook(existing, { effortGateCommand } = {}) {
231
+ const settings =
232
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
233
+ let hooks =
234
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
235
+ ? settings.hooks
236
+ : {};
237
+ if (effortGateCommand) {
238
+ hooks = mergeManagedHook(
239
+ hooks,
240
+ "PreToolUse",
241
+ "Write|Edit|MultiEdit|NotebookEdit",
242
+ effortGateCommand,
243
+ EFFORT_GATE_HOOK_STEM
244
+ );
245
+ }
246
+ settings.hooks = hooks;
247
+ return settings;
248
+ }
249
+
250
+ /**
251
+ * The removal counterpart to {@link mergeEffortGateHook}: strip the effort-gate entry (a
252
+ * no-op if it isn't present).
253
+ * @param {unknown} existing
254
+ * @returns {Record<string, unknown>}
255
+ */
256
+ export function removeEffortGateHook(existing) {
257
+ const settings =
258
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
259
+ const hadHooks =
260
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
261
+ const hooks = hadHooks ? settings.hooks : {};
262
+ return setOrDeleteHooks(
263
+ settings,
264
+ Boolean(hadHooks),
265
+ removeManagedHook(hooks, "PreToolUse", EFFORT_GATE_HOOK_STEM)
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Install (or, when a piece isn't wanted, actively REMOVE) the Claude Code native-memory
271
+ * override: copy the hook(s) into `~/.claude/hooks/`, then reconcile `autoMemoryEnabled` +
272
+ * the hook registrations in `~/.claude/settings.json` to match exactly what's wanted.
273
+ *
274
+ * Symmetric by design: every call fully reconciles state, in both directions. `enable:false`
275
+ * doesn't just skip installing the SessionStart hook — if it was installed by a PRIOR call
276
+ * (e.g. a previous run without `--no-native-memory-override`), this strips it out, same for
277
+ * `enforce:false` / `effortGate:false` against their own pieces. That's what makes `--no-X`
278
+ * flags actually reverse a prior `--X` install instead of only affecting fresh ones.
279
+ *
280
+ * As a side effect of being symmetric, a call that wants nothing installed AND finds nothing
281
+ * of ours already present is a true no-op — it never touches `~/.claude/settings.json` at
282
+ * all (not even to create it), so running with Claude Code off/unwanted never surprises a
283
+ * machine that never had this override.
284
+ *
285
+ * Best-effort and non-fatal; never throws out (a failure here must not abort the install).
286
+ * @param {string} home
287
+ * @param {string} vaultAbs
288
+ * @param {boolean} dryRun
289
+ * @param {{ lang?: "es"|"en", enable?: boolean, enforce?: boolean, effortGate?: boolean }} [opts] -
290
+ * `enable` (default true) is the top-level on/off for the whole override (SessionStart
291
+ * hook + `autoMemoryEnabled:false`); `enforce` (default true, only meaningful when
292
+ * `enable`) also installs the ADR-0030 deterministic hooks: a `PreToolUse` guard that
293
+ * denies writes into the native auto-memory directory, and a `Stop` nudge for the close
294
+ * ritual. `effortGate` (default true, only meaningful when `enable`) installs the
295
+ * ADR-0031 effort-gate hook, independently of `enforce`.
296
+ */
297
+ export async function configureClaudeNativeMemory(
298
+ home,
299
+ vaultAbs,
300
+ dryRun,
301
+ { lang = "es", enable = true, enforce = true, effortGate = true } = {}
302
+ ) {
303
+ const claudeDir = path.join(home, ".claude");
304
+ const hooksDir = path.join(claudeDir, "hooks");
305
+ const hookDest = path.join(hooksDir, HOOK_BASENAME);
306
+ const guardDest = path.join(hooksDir, GUARD_HOOK_BASENAME);
307
+ const stopDest = path.join(hooksDir, STOP_HOOK_BASENAME);
308
+ const effortGateDest = path.join(hooksDir, EFFORT_GATE_HOOK_BASENAME);
309
+ const transcriptCacheDest = path.join(hooksDir, TRANSCRIPT_CACHE_BASENAME);
310
+ const settingsFp = path.join(claudeDir, "settings.json");
311
+
312
+ const wantEnforce = enable && enforce;
313
+ const wantEffortGate = enable && effortGate;
314
+
315
+ const hookEntry = enable ? hookCommand(hookDest, vaultAbs, lang) : null;
316
+ const guardCommand = wantEnforce ? guardHookCommand(guardDest, claudeDir, lang) : null;
317
+ const stopCommand = wantEnforce ? stopHookCommand(stopDest, lang) : null;
318
+ const effortGateCommand = wantEffortGate ? effortGateHookCommand(effortGateDest, lang) : null;
319
+
320
+ try {
321
+ // Read existing settings up front — needed to reconcile installs AND removals, and to
322
+ // cheaply detect (via our filename stems, on the raw text) whether there's anything of
323
+ // ours to remove at all, without depending on the JSON being valid.
324
+ let { existing, priorBytes, invalidJson, rawText } = await readSettingsSafe(settingsFp);
325
+ const oursPresent = ALL_HOOK_STEMS.some((stem) => rawText.includes(stem));
326
+
327
+ // True no-op: nothing wanted, nothing of ours present — leave the file (even a
328
+ // nonexistent one) completely untouched. Prevents a bare `--ide claude
329
+ // --no-native-memory-override` from conjuring a settings.json out of thin air.
330
+ if (!enable && !oursPresent) {
331
+ return;
332
+ }
333
+
334
+ if (dryRun) {
335
+ if (enable) {
336
+ console.log(
337
+ pc.cyan("[dry-run] would set"),
338
+ "autoMemoryEnabled:false",
339
+ pc.dim(`in ${settingsFp}`)
340
+ );
341
+ console.log(pc.cyan("[dry-run] would install SessionStart hook"), pc.dim(hookDest));
342
+ } else if (
343
+ hasManagedHook(existing.hooks, "SessionStart", HOOK_STEM) ||
344
+ existing.autoMemoryEnabled === false
345
+ ) {
346
+ console.log(
347
+ pc.cyan("[dry-run] would remove autoMemoryEnabled override + SessionStart hook"),
348
+ pc.dim(`from ${settingsFp}`)
349
+ );
350
+ }
351
+ if (wantEnforce) {
352
+ console.log(
353
+ pc.cyan("[dry-run] would install PreToolUse native-memory guard hook"),
354
+ pc.dim(guardDest)
355
+ );
356
+ console.log(
357
+ pc.cyan("[dry-run] would install Stop close-ritual reminder hook"),
358
+ pc.dim(stopDest)
359
+ );
360
+ } else if (
361
+ hasManagedHook(existing.hooks, "PreToolUse", GUARD_HOOK_STEM) ||
362
+ hasManagedHook(existing.hooks, "Stop", STOP_HOOK_STEM)
363
+ ) {
364
+ console.log(
365
+ pc.cyan(
366
+ "[dry-run] would remove PreToolUse native-memory guard + Stop close-ritual reminder hooks"
367
+ )
368
+ );
369
+ }
370
+ if (wantEffortGate) {
371
+ console.log(
372
+ pc.cyan("[dry-run] would install PreToolUse effort-gate hook"),
373
+ pc.dim(effortGateDest)
374
+ );
375
+ } else if (hasManagedHook(existing.hooks, "PreToolUse", EFFORT_GATE_HOOK_STEM)) {
376
+ console.log(pc.cyan("[dry-run] would remove PreToolUse effort-gate hook"));
377
+ }
378
+ return;
379
+ }
380
+
381
+ // 1. Copy (only the pieces being installed) / never delete script files here — that's
382
+ // `--uninstall`'s job, guarded by a marker check. A per-run `--no-X` only touches the
383
+ // settings.json registration; an orphaned-but-uncalled script file is harmless.
384
+ await fse.ensureDir(hooksDir);
385
+ if (enable) {
386
+ await fse.copy(packagedHookPath(), hookDest, { overwrite: true });
387
+ }
388
+ if (wantEnforce) {
389
+ await fse.copy(packagedHookPath(GUARD_HOOK_BASENAME), guardDest, { overwrite: true });
390
+ await fse.copy(packagedHookPath(STOP_HOOK_BASENAME), stopDest, { overwrite: true });
391
+ }
392
+ if (wantEffortGate) {
393
+ await fse.copy(packagedHookPath(EFFORT_GATE_HOOK_BASENAME), effortGateDest, {
394
+ overwrite: true
395
+ });
396
+ }
397
+ if (wantEnforce || wantEffortGate) {
398
+ // The Stop nudge and the effort-gate hook both import this sidecar-cache module as a
399
+ // sibling file (item 3 perf fix) — copy it whenever either consumer is installed.
400
+ await fse.copy(packagedHookPath(TRANSCRIPT_CACHE_BASENAME), transcriptCacheDest, {
401
+ overwrite: true
402
+ });
403
+ }
404
+
405
+ if (invalidJson) {
406
+ const bak = await backupRestricted(settingsFp, priorBytes);
407
+ console.warn(pc.yellow("Invalid JSON in ~/.claude/settings.json; backed up to"), bak);
408
+ existing = {};
409
+ }
410
+
411
+ // 2. Reconcile: merge what's wanted, remove what's not — same code path handles a
412
+ // fresh install, a no-op re-run, and a partial/full removal.
413
+ let merged = enable
414
+ ? mergeClaudeSettings(existing, hookEntry)
415
+ : removeSessionStartHook(existing);
416
+ merged = wantEnforce
417
+ ? mergeEnforcementHooks(merged, { guardCommand, stopCommand })
418
+ : removeEnforcementHooks(merged);
419
+ merged = wantEffortGate
420
+ ? mergeEffortGateHook(merged, { effortGateCommand })
421
+ : removeEffortGateHook(merged);
422
+ // Keep a one-`mv`-away backup of the prior valid settings, like mcp.json does. Restricted
423
+ // to the current user, same as the final file below — settings.json can carry secrets in
424
+ // keys this kit doesn't own (e.g. a user's own `env` block), on either platform.
425
+ if (priorBytes && !invalidJson) {
426
+ const bak = await backupRestricted(settingsFp, priorBytes);
427
+ console.log(pc.dim("Backed up previous settings.json to"), bak);
428
+ }
429
+
430
+ // Serializes + sanity-parses + writes atomically (tmp → restrict → rename).
431
+ await atomicWriteJson(settingsFp, merged);
432
+
433
+ if (enable) {
434
+ console.log(pc.green("Claude Code native-memory override:"), settingsFp);
435
+ console.log(pc.dim(" autoMemoryEnabled:false + SessionStart hook ->"), pc.dim(hookDest));
436
+ } else if (oursPresent) {
437
+ console.log(pc.green("Claude Code native-memory override removed:"), settingsFp);
438
+ }
439
+ if (wantEnforce) {
440
+ console.log(pc.dim(" + PreToolUse native-memory guard ->"), pc.dim(guardDest));
441
+ console.log(pc.dim(" + Stop close-ritual reminder ->"), pc.dim(stopDest));
442
+ } else if (oursPresent && enable) {
443
+ console.log(pc.dim(" (enforcement hooks removed or not installed)"));
444
+ }
445
+ if (wantEffortGate) {
446
+ console.log(pc.dim(" + PreToolUse effort-gate ->"), pc.dim(effortGateDest));
447
+ }
448
+ } catch (e) {
449
+ console.warn(
450
+ pc.yellow("Could not configure the Claude Code native-memory override (skipped):"),
451
+ e?.message || e
452
+ );
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Fully reverse everything this kit's Claude Code integration installed: all 4 managed
458
+ * hook entries in `~/.claude/settings.json`, the `autoMemoryEnabled` override (iff it's
459
+ * still exactly the value this kit would have set — see {@link removeSessionStartHook}),
460
+ * and — only for files a marker check proves this kit wrote — the hook script files
461
+ * themselves under `~/.claude/hooks/`. Never touches MCP server registrations, the vault,
462
+ * or rules blocks; those have their own manual/CLI removal paths (see docs/en/faq.md).
463
+ * Best-effort and non-fatal, matching {@link configureClaudeNativeMemory}.
464
+ * @param {string} home
465
+ * @param {boolean} dryRun
466
+ */
467
+ export async function uninstallClaudeNativeMemory(home, dryRun) {
468
+ // Reuses the same reconcile path as a `--no-X` removal, just with every piece off.
469
+ await configureClaudeNativeMemory(home, "", dryRun, {
470
+ enable: false,
471
+ enforce: false,
472
+ effortGate: false
473
+ });
474
+
475
+ const hooksDir = path.join(home, ".claude", "hooks");
476
+ const basenames = [
477
+ HOOK_BASENAME,
478
+ GUARD_HOOK_BASENAME,
479
+ STOP_HOOK_BASENAME,
480
+ EFFORT_GATE_HOOK_BASENAME,
481
+ TRANSCRIPT_CACHE_BASENAME
482
+ ];
483
+ for (const basename of basenames) {
484
+ const fp = path.join(hooksDir, basename);
485
+ if (!(await fse.pathExists(fp))) continue;
486
+ const owned = await isKitOwnedFile(fp);
487
+ if (dryRun) {
488
+ console.log(
489
+ owned
490
+ ? pc.cyan("[dry-run] would remove")
491
+ : pc.yellow("[dry-run] would SKIP (not recognized as this kit's file)"),
492
+ pc.dim(fp)
493
+ );
494
+ continue;
495
+ }
496
+ if (!owned) {
497
+ console.warn(pc.yellow("Skipped (not recognized as this kit's file):"), fp);
498
+ continue;
499
+ }
500
+ try {
501
+ await fse.remove(fp);
502
+ console.log(pc.green("Removed"), pc.dim(fp));
503
+ } catch (e) {
504
+ console.warn(pc.yellow("Could not remove"), fp, e?.message || e);
505
+ }
506
+ }
507
+ }
@@ -0,0 +1,53 @@
1
+ // Restrict a just-written config file (and its `.bak.*` copies) to the current user.
2
+ //
3
+ // `mcp.json` / `settings.json` may carry `env` blocks with API tokens (this kit's own
4
+ // writes don't add secrets, but we merge into and preserve whatever else the user already
5
+ // had in the file, which can). POSIX: chmod 0600. Windows has no chmod bits at all — the
6
+ // prior code just skipped the whole step there (`if (process.platform !== "win32")`), which
7
+ // on the platform this repo's own maintainer uses meant the protection never applied, and
8
+ // `.bak.*` copies accumulated with default (often broader) ACLs. This restricts the file to
9
+ // the current user via `icacls`, the Windows analogue of chmod 0600 (ADR-none; audit fix).
10
+ //
11
+ // Best-effort by design, on BOTH platforms: a permissions failure must never abort a write
12
+ // that otherwise succeeded (matches the existing chmod call sites' `catch { /* ignore */ }`
13
+ // precedent) — ACL manipulation in particular can be fragile across Windows configs (domain
14
+ // accounts, non-NTFS volumes, restricted environments), so failures here only warn.
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import fs from "node:fs/promises";
18
+ import os from "node:os";
19
+ import pc from "picocolors";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ /**
24
+ * Best-effort: restrict `fp` to the current user only (POSIX: chmod 0600; Windows: icacls
25
+ * ACL reset). Never throws — callers should NOT await-fail their write on this.
26
+ * @param {string} fp
27
+ */
28
+ export async function restrictFileToOwner(fp) {
29
+ if (process.platform === "win32") {
30
+ try {
31
+ const username = process.env.USERNAME || os.userInfo().username;
32
+ if (!username) return;
33
+ // /inheritance:r drops inherited ACEs; /grant:r replaces the explicit grant list with
34
+ // just the current user (Full control) — together, the closest Windows analogue of
35
+ // POSIX chmod 0600. Passed as an argv array (no shell), so no quoting to get wrong.
36
+ await execFileAsync("icacls", [fp, "/inheritance:r", "/grant:r", `${username}:F`], {
37
+ windowsHide: true
38
+ });
39
+ } catch (e) {
40
+ console.warn(
41
+ pc.yellow("Could not restrict file permissions (icacls, best-effort):"),
42
+ fp,
43
+ e?.message || e
44
+ );
45
+ }
46
+ return;
47
+ }
48
+ try {
49
+ await fs.chmod(fp, 0o600);
50
+ } catch {
51
+ /* best-effort: not all filesystems support chmod */
52
+ }
53
+ }