@lorekit/cli 1.15.0 → 1.16.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/README.md CHANGED
@@ -483,6 +483,16 @@ Both files share this schema — all fields optional:
483
483
  // values: "claude" | "cursor" | "codex"
484
484
  // repo wins over user
485
485
 
486
+ "hooks.instructions": {
487
+ "SessionStart": "Focus on migration safety. Treat any lesson tagged 'migration' as high-priority.",
488
+ "PostToolUseFailure": "When recording a failure, always include the exact command and exit code.",
489
+ "Stop": null
490
+ },
491
+ // per-event custom text appended to the hook output.
492
+ // both layers merged: repo instructions first, then user.
493
+ // null (or absent key) means no extra instruction for that event.
494
+ // values: string | null (keys: "SessionStart" | "PostToolUseFailure" | "Stop")
495
+
486
496
  // ── Telemetry ──────────────────────────────────────────────────────────────
487
497
  "telemetry.disabled": true,
488
498
  // team-level opt-out for orgs with a no-telemetry policy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.15.0",
3
+ "version": "1.16.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": {
package/src/control.mjs CHANGED
@@ -137,6 +137,27 @@ export function resolveControl({
137
137
  (typeof userConfig['hooks.adapter'] === 'string' && userConfig['hooks.adapter'].trim()) ||
138
138
  null;
139
139
 
140
+ // `hooks.instructions` — per-event custom text appended to the hook output so
141
+ // teams can embed project-specific guidance directly into the injected context.
142
+ // Both layers contribute: repo instructions come first, user instructions follow
143
+ // (same direction as `tags.default` — repo supplements, user personalises).
144
+ // null for a given event means "no custom instruction for that event".
145
+ const HOOK_EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
146
+ const hooksInstructions = {};
147
+ {
148
+ const repoInstr =
149
+ (repoConfig['hooks.instructions'] && typeof repoConfig['hooks.instructions'] === 'object')
150
+ ? repoConfig['hooks.instructions'] : {};
151
+ const userInstr =
152
+ (userConfig['hooks.instructions'] && typeof userConfig['hooks.instructions'] === 'object')
153
+ ? userConfig['hooks.instructions'] : {};
154
+ for (const ev of HOOK_EVENTS) {
155
+ const parts = [repoInstr[ev], userInstr[ev]]
156
+ .filter((v) => typeof v === 'string' && v.trim().length > 0);
157
+ hooksInstructions[ev] = parts.length > 0 ? parts.join('\n') : null;
158
+ }
159
+ }
160
+
140
161
  return {
141
162
  mode: chosen.mode,
142
163
  storeTarget,
@@ -147,6 +168,7 @@ export function resolveControl({
147
168
  scopeDefaults,
148
169
  hooksDisabled,
149
170
  hooksAdapter,
171
+ hooksInstructions,
150
172
  };
151
173
  }
152
174
 
@@ -51,19 +51,56 @@ export async function fetchLessons(store, cwd) {
51
51
  return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
52
52
  }
53
53
 
54
- // Render lessons as a compact markdown block, or null when there are none.
55
- export function formatLessons(lessons, scope) {
56
- if (!lessons || lessons.length === 0) return null;
54
+ // Cap on a lesson's one-line hook in the injected index. Long enough to jog
55
+ // recognition, short enough that N lessons stay a scannable list, not a wall —
56
+ // the full text is always one `memory.read` away.
57
+ const HOOK_LEN = 80;
58
+
59
+ // A lesson's first meaningful line, cleaned into a short recognisable hook:
60
+ // skips leading HTML-comment metadata (`<!-- ... -->`) and markdown heading
61
+ // marks, collapses whitespace, and truncates on a word boundary with an
62
+ // ellipsis — so nothing is ever cut mid-word into noise like "cascades to GE".
63
+ function lessonHook(value, max = HOOK_LEN) {
64
+ let first = '';
65
+ for (const raw of String(value || '').split('\n')) {
66
+ const line = raw.trim();
67
+ if (!line || line.startsWith('<!--')) continue; // skip blanks + meta comments
68
+ first = line.replace(/^#+\s*/, ''); // strip markdown heading marks
69
+ if (first) break;
70
+ }
71
+ first = first.replace(/\s+/g, ' ').trim();
72
+ if (first.length <= max) return first;
73
+ const clipped = first.slice(0, max);
74
+ const lastSpace = clipped.lastIndexOf(' ');
75
+ return `${(lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
76
+ }
77
+
78
+ // Render the SessionStart block as a compact INDEX — one terse line per lesson
79
+ // (scope, key, and a short hook), never the full bodies. This mirrors the
80
+ // lorekit-memory intake rule ("report briefly") and the MEMORY.md index pattern:
81
+ // surface WHAT is known so the agent can `memory.read` the one lesson that turns
82
+ // out to matter, instead of paying for every body up front. Null when empty.
83
+ // `instruction` — an optional extra line appended after the index, sourced from
84
+ // `hooks.instructions.SessionStart` in the control config. Lets teams inject
85
+ // project-specific guidance (e.g. "focus on migration safety") without touching
86
+ // the hook internals. Visible even when there are no lessons.
87
+ export function formatLessons(lessons, scope, { instruction = null } = {}) {
88
+ const noun = lessons && lessons.length === 1 ? 'memory' : 'memories';
89
+ if (!lessons || lessons.length === 0) {
90
+ // No lessons — only emit if there is a custom instruction to show.
91
+ if (!instruction) return null;
92
+ return (
93
+ `LoreKit: 0 ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
94
+ `— considerations, not rules; read any in full with memory.read.\n\n` +
95
+ `Project instruction: ${instruction}`
96
+ );
97
+ }
57
98
  const header =
58
- `LoreKit ${lessons.length} shared ${lessons.length === 1 ? 'memory' : 'memories'} for ${scope.repoScope || 'this workspace'}. ` +
59
- `Treat as considerations, not rules; trust the current code if they conflict.`;
60
- const body = lessons
61
- .map((l) => {
62
- const first = String(l.value || '').split('\n')[0].slice(0, 300);
63
- return `- (${l.scope}) ${l.key}: ${first}`;
64
- })
65
- .join('\n');
66
- return `${header}\n${body}`;
99
+ `LoreKit: ${lessons.length} ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
100
+ `— considerations, not rules; read any in full with memory.read.`;
101
+ const body = lessons.map((l) => `- (${l.scope}) ${l.key} — ${lessonHook(l.value)}`).join('\n');
102
+ const instructionBlock = instruction ? `\n\nProject instruction: ${instruction}` : '';
103
+ return `${header}\n${body}${instructionBlock}`;
67
104
  }
68
105
 
69
106
  // Distil a small set of significant, lowercased search TERMS from a tool
@@ -104,19 +141,16 @@ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
104
141
  }
105
142
 
106
143
  // Render the relevant-lessons block injected alongside the failure nudge, or
107
- // null when nothing matched. Framed as prior art, not a directive — same
108
- // "considerations, not rules" stance as `formatLessons`.
144
+ // null when nothing matched. Same compact-index shape as `formatLessons`, with a
145
+ // touch more hook per line (there are at most MAX_RELEVANT and they're directly
146
+ // actionable). Framed as prior art, not a directive.
109
147
  export function formatRelevantLessons(lessons) {
110
148
  if (!lessons || lessons.length === 0) return null;
149
+ const noun = lessons.length === 1 ? 'memory' : 'memories';
111
150
  const header =
112
- `LoreKit — you've hit something like this before. ${lessons.length} related ` +
113
- `${lessons.length === 1 ? 'memory' : 'memories'} (considerations, not rules; trust the current code if they conflict):`;
114
- const body = lessons
115
- .map((l) => {
116
- const first = String(l.value || '').split('\n')[0].slice(0, 300);
117
- return `- (${l.scope}) ${l.key}: ${first}`;
118
- })
119
- .join('\n');
151
+ `LoreKit: ${lessons.length} related ${noun} — you've hit something like this before ` +
152
+ `(considerations, not rules; read in full with memory.read):`;
153
+ const body = lessons.map((l) => `- (${l.scope}) ${l.key} — ${lessonHook(l.value, 140)}`).join('\n');
120
154
  return `${header}\n${body}`;
121
155
  }
122
156
 
@@ -146,12 +180,12 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
146
180
  export function retrospectiveNudge(scope, control) {
147
181
  const writeScope = scope.repoScope || 'global';
148
182
  const hint = tagsHint(writeScope, control);
183
+ const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
184
+ ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
149
185
  return (
150
- 'LoreKit retrospective: if this session hit a stuck loop, a repeated ' +
151
- 'command failure, a surprising gotcha, a near-miss, or a wrong assumption ' +
152
- 'that cost time, record it now via the lorekit-memory skill ' +
153
- `(memory.write to ${writeScope}, phrased as an observation).${hint} ` +
154
- 'If nothing was durable, do nothing.'
186
+ `LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
187
+ `failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
188
+ `as an observation; else skip.${hint}${instruction}`
155
189
  );
156
190
  }
157
191
 
@@ -161,11 +195,11 @@ export function retrospectiveNudge(scope, control) {
161
195
  export function failureNudge(toolName, scope, control) {
162
196
  const writeScope = scope.repoScope || 'global';
163
197
  const hint = tagsHint(writeScope, control);
164
- const suffix = hint ? `${hint} So the next run avoids it.` : 'so the next run avoids it.';
198
+ const instruction = control && control.hooksInstructions && control.hooksInstructions.PostToolUseFailure
199
+ ? `\n\nProject instruction: ${control.hooksInstructions.PostToolUseFailure}` : '';
165
200
  return (
166
- `LoreKit: the last ${toolName} call failed. If this is a recurring or ` +
167
- 'non-obvious failure, consider recording the fix as a memory via ' +
168
- `lorekit-memory (memory.write to ${writeScope}) — ${suffix}`
201
+ `LoreKit: the last ${toolName} call failed. If it's recurring or non-obvious, ` +
202
+ `memory.write to ${writeScope} with the fix so the next run avoids it.${hint}${instruction}`
169
203
  );
170
204
  }
171
205
 
package/src/doctor.mjs CHANGED
@@ -88,7 +88,24 @@ export async function doctor(args) {
88
88
  record('warn', 'scope', 'no git remote here — memories fall back to global');
89
89
  }
90
90
 
91
- // 6. doctor.requirecommitted list of checks that MUST pass.
91
+ // 6. Hook instructions show resolved per-event custom instructions when any are set.
92
+ {
93
+ const instr = control.hooksInstructions || {};
94
+ const EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
95
+ const configured = EVENTS.filter((ev) => instr[ev]);
96
+ if (configured.length > 0) {
97
+ for (const ev of EVENTS) {
98
+ const text = instr[ev];
99
+ if (text) {
100
+ record('info', `hooks.instructions.${ev}`, c.dim(text.length > 80 ? text.slice(0, 77) + '…' : text));
101
+ } else {
102
+ record('info', `hooks.instructions.${ev}`, c.dim('(not set)'));
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // 7. doctor.require — committed list of checks that MUST pass.
92
109
  // Useful as a CI gate: any check in the list that did not pass causes a failure.
93
110
  const lorekitJson = readLorekitJson(root);
94
111
  const required = (Array.isArray(lorekitJson['doctor.require']) ? lorekitJson['doctor.require'] : [])
package/src/hook.mjs CHANGED
@@ -93,9 +93,19 @@ async function run(args) {
93
93
  if (intent === 'read') {
94
94
  if (!firstTimeThisSession(parsed.sessionId, 'read')) return 0;
95
95
  const store = createStore(control);
96
- if (!store) return 0; // unconfigured/unusable stay silent
96
+ // When there is no usable store, we still want to emit a custom instruction
97
+ // if one is configured — so we don't bail out entirely on a missing store.
98
+ const sessionInstruction = control.hooksInstructions && control.hooksInstructions.SessionStart
99
+ ? control.hooksInstructions.SessionStart : null;
100
+ if (!store) {
101
+ // No store: emit a minimal header + instruction when present, then return.
102
+ if (sessionInstruction) {
103
+ emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
104
+ }
105
+ return 0;
106
+ }
97
107
  const { scope: readScope, lessons } = await fetchLessons(store, root);
98
- emit(formatLessons(lessons, readScope));
108
+ emit(formatLessons(lessons, readScope, { instruction: sessionInstruction }));
99
109
  return 0;
100
110
  }
101
111