@lorekit/cli 1.17.0 → 1.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,7 @@ export const claude = {
9
9
  case 'SessionStart':
10
10
  return 'read';
11
11
  case 'PostToolUse':
12
+ return 'confirm';
12
13
  case 'PostToolUseFailure':
13
14
  return 'failure';
14
15
  case 'Stop':
@@ -23,11 +24,22 @@ export const claude = {
23
24
  return event === 'PostToolUseFailure';
24
25
  },
25
26
 
27
+ // Returns true when the PostToolUse event was a successful lorekit memory
28
+ // write. Claude Code reports MCP tool names as
29
+ // "mcp__<server-label>__memory_write" (underscores) — we match the suffix
30
+ // so any server label works. A successful write response always contains
31
+ // a string `id` field returned by the memory_write RPC.
32
+ isLoreWrite(toolName, toolResponse) {
33
+ if (!toolName || !String(toolName).endsWith('memory_write')) return false;
34
+ return toolResponse != null && typeof toolResponse === 'object' && typeof toolResponse.id === 'string';
35
+ },
36
+
26
37
  parse(input) {
27
38
  return {
28
39
  cwd: input.cwd || null,
29
40
  sessionId: input.session_id || null,
30
41
  toolName: input.tool_name || 'tool',
42
+ toolInput: input.tool_input || null,
31
43
  toolResponse: input.tool_response || null,
32
44
  event: input.hook_event_name || null,
33
45
  };
@@ -174,6 +174,14 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
174
174
  return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
175
175
  }
176
176
 
177
+ // The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
178
+ // Exported so tests can assert the URL shape without re-deriving the encoding.
179
+ export function loreUrl(writeScope) {
180
+ const base = 'https://lorekit.io/lore';
181
+ if (!writeScope || writeScope === 'global') return base;
182
+ return `${base}?scope=${encodeURIComponent(writeScope)}`;
183
+ }
184
+
177
185
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
178
186
  // `control` is the resolved control object (optional) — carries tagsDefault and
179
187
  // scopeDefaults when the repo/user config defines them.
@@ -182,13 +190,28 @@ export function retrospectiveNudge(scope, control) {
182
190
  const hint = tagsHint(writeScope, control);
183
191
  const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
184
192
  ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
193
+ const url = loreUrl(writeScope);
185
194
  return (
186
195
  `LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
187
196
  `failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
188
- `as an observation; else skip.${hint}${instruction}`
197
+ `as an observation; else skip.${hint}${instruction}\n` +
198
+ `View lore: ${url}`
189
199
  );
190
200
  }
191
201
 
202
+ // Terse confirmation emitted via PostToolUse when a memory.write succeeded.
203
+ // `key` is the lesson key from the tool response (may be null when the response
204
+ // shape doesn't surface it). Includes a deep link to the scope's Lore Explorer
205
+ // page so the user can verify immediately.
206
+ export function writeConfirmation(scope, key) {
207
+ const writeScope = scope.repoScope || 'global';
208
+ const keyPart = key ? ` · ${key}` : '';
209
+ const url = key
210
+ ? `${loreUrl(writeScope)}&q=${encodeURIComponent(key)}`
211
+ : loreUrl(writeScope);
212
+ return `LoreKit: memory saved to ${writeScope}${keyPart}\nView: ${url}`;
213
+ }
214
+
192
215
  // The nudge emitted when a tool failure is detected.
193
216
  // `control` is the resolved control object (optional) — carries tagsDefault and
194
217
  // scopeDefaults when the repo/user config defines them.
package/src/doctor.mjs CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  SKILLS,
9
9
  resolveProjectRoot,
10
10
  skillInstallDir,
11
+ settingsPath,
12
+ CLAUDE_HOOK_EVENTS,
13
+ LOREKIT_HOOK_RE,
11
14
  readLorekitServer,
12
15
  readMcpConfig,
13
16
  tokenKind,
@@ -63,6 +66,22 @@ export async function doctor(args) {
63
66
  }
64
67
  }
65
68
 
69
+ // 2.5. Duplicate-hook detection — warn when the same lorekit hook event is
70
+ // wired in both the project settings and the global settings. This causes
71
+ // Claude Code to fire the hook twice per event, producing doubled terminal
72
+ // output. Common after running `lorekit install` once with --project and
73
+ // once with --global (or via the marketplace plugin on top of a CLI install).
74
+ const dupeEvents = detectDuplicateHooks(root);
75
+ if (dupeEvents.length > 0) {
76
+ record(
77
+ 'warn',
78
+ 'hooks duplicate',
79
+ `${dupeEvents.join(', ')} registered in BOTH project and global settings — ` +
80
+ `Claude Code fires them twice. Remove one scope: ` +
81
+ `run \`lorekit uninstall --project\` or \`lorekit uninstall --global\`.`,
82
+ );
83
+ }
84
+
66
85
  // 3. Resolved control model — which mode, and who decided it.
67
86
  const control = loadControl(root, { env: withOverrides(args) });
68
87
  record('info', 'memory mode', `${control.mode} ${c.dim('— decided by ' + control.decidedBy)}`);
@@ -82,8 +101,9 @@ export async function doctor(args) {
82
101
  // 5. Scope.
83
102
  const scope = deriveScope(root);
84
103
  if (scope.hasRemote) {
104
+ log('');
85
105
  record('info', 'read scope', scope.readOrder.join(' → '));
86
- record('info', 'write scope', `${scope.repoScope} (default for "went wrong" memories)`);
106
+ record('info', 'write scope', `${scope.repoScope} (default write target)`);
87
107
  } else {
88
108
  record('warn', 'scope', 'no git remote here — memories fall back to global');
89
109
  }
@@ -289,6 +309,48 @@ async function deepCheckLocal(store, scope, record) {
289
309
  await store.delete({ scope: writeScope, key, force: true });
290
310
  }
291
311
 
312
+ // Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
313
+ // BOTH the project settings file (.claude/settings.json) and the global one
314
+ // (~/.claude/settings.json). An empty array means no duplicates — healthy.
315
+ function detectDuplicateHooks(root) {
316
+ const dupes = [];
317
+ const projectFile = settingsPath(root, 'project');
318
+ const globalFile = settingsPath(root, 'global');
319
+
320
+ let projectHooks = {};
321
+ let globalHooks = {};
322
+ try {
323
+ const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
324
+ if (cfg && typeof cfg.hooks === 'object') projectHooks = cfg.hooks;
325
+ } catch { /* absent or unparseable — treat as empty */ }
326
+ try {
327
+ const cfg = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
328
+ if (cfg && typeof cfg.hooks === 'object') globalHooks = cfg.hooks;
329
+ } catch { /* absent or unparseable — treat as empty */ }
330
+
331
+ for (const event of CLAUDE_HOOK_EVENTS) {
332
+ const hasInProject = hooksForEvent(projectHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
333
+ const hasInGlobal = hooksForEvent(globalHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
334
+ if (hasInProject && hasInGlobal) dupes.push(event);
335
+ }
336
+ return dupes;
337
+ }
338
+
339
+ // Extract the flat list of hook command strings for one event from a hooks
340
+ // object. Handles the nested-group shape Claude Code uses:
341
+ // { [event]: [ { hooks: [ { type, command } ] } ] }
342
+ function hooksForEvent(hooksObj, event) {
343
+ const groups = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
344
+ const commands = [];
345
+ for (const group of groups) {
346
+ const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
347
+ for (const h of inner) {
348
+ if (h && typeof h.command === 'string') commands.push(h.command);
349
+ }
350
+ }
351
+ return commands;
352
+ }
353
+
292
354
  function gitTracked(root, dir) {
293
355
  // Heuristic: is the store dir ignored by git? If `git check-ignore` names it,
294
356
  // it is private; otherwise it will be committed (team-shared).
package/src/hook.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  failureQuery,
16
16
  relevantLessons,
17
17
  formatRelevantLessons,
18
+ writeConfirmation,
18
19
  } from './core/lessons.mjs';
19
20
  import { isFailure } from './core/failure.mjs';
20
21
  import { firstTimeThisSession } from './core/state.mjs';
@@ -109,6 +110,26 @@ async function run(args) {
109
110
  return 0;
110
111
  }
111
112
 
113
+ if (intent === 'confirm') {
114
+ // Fire only when a lorekit memory write actually succeeded — the adapter's
115
+ // isLoreWrite() inspects the tool name and the response shape. Any error
116
+ // is swallowed (exit 0 — never block the host).
117
+ try {
118
+ if (adapter.isLoreWrite && adapter.isLoreWrite(parsed.toolName, parsed.toolResponse)) {
119
+ // The lesson key comes from the tool INPUT (what the agent sent), not
120
+ // the response (which only carries id + created_at). toolInput is
121
+ // populated by the adapter's parse() from the raw hook stdin.
122
+ const key = (parsed.toolInput && typeof parsed.toolInput.key === 'string')
123
+ ? parsed.toolInput.key
124
+ : null;
125
+ emit(writeConfirmation(scope, key));
126
+ }
127
+ } catch {
128
+ // best-effort — never break the host
129
+ }
130
+ return 0;
131
+ }
132
+
112
133
  if (intent === 'failure') {
113
134
  const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
114
135
  if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
package/src/telemetry.mjs CHANGED
@@ -32,7 +32,7 @@ import { readLorekitJson } from './config.mjs';
32
32
  // (empty in the source tree, so default export stays off until built/injected).
33
33
  const DEFAULT_ENDPOINT = 'https://ingress.europe-west4.gcp.dash0-dev.com';
34
34
  const DEFAULT_TOKEN = TELEMETRY_TOKEN; // injected from LOREKIT_TELEMETRY_TOKEN at publish
35
- const DEFAULT_DATASET = 'lorekit-cli';
35
+ const DEFAULT_DATASET = '';
36
36
 
37
37
  // Flags worth counting (e.g. how many installs are --global). Bounded on
38
38
  // purpose: only these booleans are ever attached, never free-form values.