@lorekit/cli 1.10.0 → 1.12.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
@@ -51,9 +51,11 @@ without needing a marketplace:
51
51
  2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
52
52
  MCP config (preserving any other servers).
53
53
  3. **Hooks** — the *deterministic* layer: lessons injected on every
54
- `SessionStart`, a nudge on tool failure (`PostToolUseFailure`), and a
55
- retrospective nudge on `Stop`. These fire the shared `lorekit hook` engine
56
- and are merged into `settings.json` (existing hooks preserved).
54
+ `SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
55
+ look **relevant to that failure** ("you've hit this before") plus a nudge to
56
+ record the fix, and a retrospective nudge on `Stop`. These fire the shared
57
+ `lorekit hook` engine and are merged into `settings.json` (existing hooks
58
+ preserved).
57
59
 
58
60
  It first asks **where** to install:
59
61
 
@@ -210,13 +212,72 @@ remote is unconfigured (or a store is denied), a meaningful diff is impossible,
210
212
  so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
211
213
  rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
212
214
 
215
+ ### `lorekit tree` (alias `resolve`)
216
+
217
+ Show the scopes the hooks actually **inject** — project → branch → repo →
218
+ global, in precedence order (most-specific first) — as a resolution hierarchy,
219
+ and mark for any key present at more than one scope which scope's lesson **wins**
220
+ and which are **shadowed**:
221
+
222
+ ```bash
223
+ lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
224
+ lorekit tree --scope global # narrow to a single scope
225
+ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[] list
226
+ ```
227
+
228
+ This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
229
+ in `readOrder` (project → branch → repo → global) and keeps the first value seen
230
+ per key, so a more-specific scope overrides a broader scope's same-key lesson. It
231
+ answers "which lesson actually applies here, and what is being overridden?". The
232
+ `project::` scope **is** part of the injected set (project is the most-specific
233
+ scope), so `tree`, the hooks, and every read command's `scopeList` now share one
234
+ ordering. Each store is resolved independently, in the same Offline / Remote split.
235
+
236
+ ### `lorekit lint`
237
+
238
+ Flag low-quality lessons across the applicable scopes and both stores. Each
239
+ finding names the rule it violated:
240
+
241
+ ```bash
242
+ lorekit lint # findings grouped by scope; exits non-zero if any
243
+ lorekit lint --scope global # narrow to a single scope
244
+ lorekit lint --json # { total, offline, remote } structured findings
245
+ ```
246
+
247
+ Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
248
+ body below a small length threshold), **untrimmed-value** (real content with
249
+ surrounding whitespace), **empty-key** (blank key), and **malformed-scope** (e.g.
250
+ a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
251
+ is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
252
+ or one where only a store is unavailable — exits 0. The pure rule predicates live
253
+ in `lessons-view.mjs` and are unit-tested one rule at a time.
254
+
255
+ ### `lorekit dedupe`
256
+
257
+ Find likely-duplicate lessons and group them into clusters — per store, across
258
+ the applicable scopes:
259
+
260
+ ```bash
261
+ lorekit dedupe # clusters of near-duplicate lessons per store
262
+ lorekit dedupe --threshold 0.6 # loosen the similarity cutoff (default 0.8)
263
+ lorekit dedupe --json # { threshold, offline, remote } clusters + signal
264
+ ```
265
+
266
+ The similarity signal is a zero-dependency **heuristic** — Jaccard overlap of
267
+ lowercased word tokens, **not** a semantic/embedding measure — so it surfaces
268
+ candidates for a human to review and can both miss paraphrases and group
269
+ coincidental overlaps. Any pair scoring at or above `--threshold` links (transitively)
270
+ into one cluster; only clusters of 2+ members are reported, each with a similarity
271
+ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
272
+
213
273
  ### `lorekit hook`
214
274
 
215
275
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
216
276
  It is not run by hand — the plugins wire it into their hook config. It reads
217
277
  the host framework's JSON on stdin and prints that host's injection format on
218
- stdout (lessons at session start; a nudge on failure or at end of turn),
219
- always exiting 0 so it can never block the host agent.
278
+ stdout (lessons at session start; relevant lessons plus a write-nudge on a tool
279
+ failure; a retrospective nudge at end of turn), always exiting 0 so it can never
280
+ block the host agent.
220
281
 
221
282
  ```bash
222
283
  lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
@@ -392,8 +453,9 @@ active deny constraints.
392
453
  | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
393
454
  | `--force` | Overwrite existing skill files (`install`) |
394
455
  | `--deep` | Write/read/delete round-trip (`doctor`) |
395
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff`) |
396
- | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff`; default: all applicable) |
456
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff` / `tree` / `lint` / `dedupe`) |
457
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable) |
458
+ | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
397
459
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
398
460
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
399
461
  | `-h, --help` | Help |
package/bin/lorekit.mjs CHANGED
@@ -11,6 +11,9 @@ import { search } from '../src/search.mjs';
11
11
  import { show } from '../src/show.mjs';
12
12
  import { stats } from '../src/stats.mjs';
13
13
  import { diff } from '../src/diff.mjs';
14
+ import { tree } from '../src/tree.mjs';
15
+ import { lint } from '../src/lint.mjs';
16
+ import { dedupe } from '../src/dedupe.mjs';
14
17
  import { hook } from '../src/hook.mjs';
15
18
  import { migrate } from '../src/migrate.mjs';
16
19
  import { mcpServer } from '../src/mcp-server.mjs';
@@ -56,6 +59,15 @@ ${c.bold('Commands')}
56
59
  diff Compare the offline and remote stores for the applicable scopes and
57
60
  report divergence: local-only, remote-only, and conflicting keys
58
61
  (grouped by scope). Needs both stores readable. --json, --scope <s>.
62
+ tree Show the injected scopes (branch → repo → global) as a precedence
63
+ (resolve) hierarchy and mark, per key, which scope's lesson WINS and which are
64
+ shadowed — the real hook-resolution order. --json, --scope <s>.
65
+ lint Flag low-quality lessons (empty/short/untrimmed value, empty key,
66
+ malformed scope) across the applicable scopes and both stores. Exits
67
+ non-zero when issues are found (CI gate). --json, --scope <s>.
68
+ dedupe Find likely-duplicate lessons via a zero-dep word-overlap HEURISTIC
69
+ (Jaccard >= threshold, not semantic), grouped into clusters per
70
+ store. --json, --scope <s>, --threshold <0..1>.
59
71
  migrate Relocate a LoreKit-format local store into the current layout.
60
72
  Dry-run by default; pass --yes to apply. Idempotent.
61
73
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
@@ -74,8 +86,9 @@ ${c.bold('Options')}
74
86
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
75
87
  --mode <mode> Memory mode: off | local | remote (doctor override)
76
88
  --store <path> Local project-tier store directory (default: .lorekit)
77
- --json Machine-readable output (list / search / show / stats / diff)
78
- --scope <scope> Restrict to a single scope (list / search / stats / diff)
89
+ --json Machine-readable output (list / search / show / stats / diff / tree / lint / dedupe)
90
+ --scope <scope> Restrict to a single scope (list / search / stats / diff / tree / lint / dedupe)
91
+ --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
79
92
  --from <path> Source store to migrate from (migrate)
80
93
  --to <tier> Migration destination tier: home | project (migrate;
81
94
  default routes each entry by scope)
@@ -295,6 +308,80 @@ ${c.bold('Examples')}
295
308
  npx @lorekit/cli diff
296
309
  npx @lorekit/cli diff --json
297
310
  npx @lorekit/cli diff --scope global
311
+ `,
312
+ tree: `${c.bold('lorekit tree')} — show the scope precedence hierarchy and which lesson wins ${c.dim('(alias: resolve)')}
313
+
314
+ ${c.bold('Usage')}
315
+ npx @lorekit/cli tree [options]
316
+
317
+ Shows the scopes the hooks actually inject for the current directory — branch,
318
+ repo, global, in precedence order (most-specific first) — and marks, for any key
319
+ present at more than one scope, which scope's lesson WINS and which are shadowed.
320
+ This mirrors the SessionStart hook's resolution exactly (a more-specific scope
321
+ overrides a broader scope's same-key lesson). Project-scope lessons are NOT
322
+ injected by the hooks, so they are not shown here — browse them with \`lorekit list\`.
323
+ Resolved independently per store, in the same Offline / Remote split.
324
+
325
+ ${c.bold('Options')}
326
+ -d, --dir <path> Target project root (default: current directory)
327
+ --scope <scope> Restrict to a single scope (default: the injected set)
328
+ --json Machine-readable output (per-entry winning/shadowedBy tags)
329
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
330
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
331
+ --store <path> Local project-tier store directory (default: .lorekit)
332
+
333
+ ${c.bold('Examples')}
334
+ npx @lorekit/cli tree
335
+ npx @lorekit/cli resolve --json
336
+ `,
337
+ lint: `${c.bold('lorekit lint')} — flag low-quality lessons across the applicable scopes
338
+
339
+ ${c.bold('Usage')}
340
+ npx @lorekit/cli lint [options]
341
+
342
+ Checks every lesson for the current directory's scopes (project/branch/repo/
343
+ global), across both stores, against a small set of quality rules: empty or
344
+ whitespace-only value, suspiciously short value, untrimmed value, empty key, and
345
+ malformed scope (e.g. a single \`:\` where \`::\` is expected). Each finding names
346
+ the rule it violated. Exits NON-ZERO when any issue is found, so it works as a CI
347
+ gate; a clean run — or one where only a store is unavailable — exits 0.
348
+
349
+ ${c.bold('Options')}
350
+ -d, --dir <path> Target project root (default: current directory)
351
+ --scope <scope> Restrict to a single scope (default: all applicable)
352
+ --json Machine-readable output (structured findings list)
353
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
354
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
355
+ --store <path> Local project-tier store directory (default: .lorekit)
356
+
357
+ ${c.bold('Examples')}
358
+ npx @lorekit/cli lint
359
+ npx @lorekit/cli lint --json
360
+ npx @lorekit/cli lint --scope global
361
+ `,
362
+ dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate lessons (heuristic)
363
+
364
+ ${c.bold('Usage')}
365
+ npx @lorekit/cli dedupe [options]
366
+
367
+ Groups lessons whose values overlap heavily into duplicate clusters, per store,
368
+ across the current directory's scopes. The similarity signal is a zero-dependency
369
+ HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embedding
370
+ measure — so it surfaces candidates for a human to review, and can both miss
371
+ paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
372
+
373
+ ${c.bold('Options')}
374
+ -d, --dir <path> Target project root (default: current directory)
375
+ --scope <scope> Restrict to a single scope (default: all applicable)
376
+ --threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
377
+ --json Machine-readable output (clusters + similarity signal)
378
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
379
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
380
+ --store <path> Local project-tier store directory (default: .lorekit)
381
+
382
+ ${c.bold('Examples')}
383
+ npx @lorekit/cli dedupe
384
+ npx @lorekit/cli dedupe --threshold 0.6 --json
298
385
  `,
299
386
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
300
387
 
@@ -348,19 +435,20 @@ ${c.bold('Options')}
348
435
  const KNOWN_FLAGS = [
349
436
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
350
437
  'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
351
- 'event', 'json', 'scope', 'help', 'version',
438
+ 'event', 'json', 'scope', 'threshold', 'help', 'version',
352
439
  ];
353
440
 
354
441
  // Commands that write to disk / talk to the network on a human's behalf. These
355
442
  // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
356
443
  // never fail on a stray flag, and only ever receive flags we control).
357
444
  const HUMAN_COMMANDS = new Set([
358
- 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff', 'migrate',
445
+ 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
446
+ 'tree', 'lint', 'dedupe', 'migrate',
359
447
  ]);
360
448
 
361
449
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
362
450
  // and telemetry both resolve to the real command name.
363
- const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
451
+ const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
364
452
 
365
453
  async function main() {
366
454
  // Load a `.env` from the current directory (if any) before anything reads the
@@ -439,6 +527,12 @@ async function main() {
439
527
  return traceCommand('stats', args, VERSION, () => stats(args));
440
528
  case 'diff':
441
529
  return traceCommand('diff', args, VERSION, () => diff(args));
530
+ case 'tree':
531
+ return traceCommand('tree', args, VERSION, () => tree(args));
532
+ case 'lint':
533
+ return traceCommand('lint', args, VERSION, () => lint(args));
534
+ case 'dedupe':
535
+ return traceCommand('dedupe', args, VERSION, () => dedupe(args));
442
536
  case 'migrate':
443
537
  return traceCommand('migrate', args, VERSION, () => migrate(args));
444
538
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.10.0",
3
+ "version": "1.12.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": {
@@ -3,23 +3,52 @@
3
3
  // Storage is reached through the resolved store (local | remote), never a
4
4
  // backend directly, so the same read path serves every mode.
5
5
  import { deriveScope } from '../scope.mjs';
6
+ // The precedence merge and the literal substring matcher come from the
7
+ // dependency-free `lessons-pure.mjs` — the SAME primitives `tree` and `search`
8
+ // use, so the hook can't drift from them, and the hot path never pulls in the
9
+ // `lessons-view.mjs` render/`util` stack.
10
+ import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
6
11
 
7
12
  const MAX_LESSONS = 15;
13
+ // Cap on lessons injected on a failure — a small, focused "you've seen this
14
+ // before" set, never the whole applicable corpus.
15
+ const MAX_RELEVANT = 3;
16
+ // Cap on the terms distilled from a failure — bounds the relevance scan so a
17
+ // huge error blob can never turn into an unbounded match loop.
18
+ const MAX_TERMS = 12;
19
+ // Terms shorter than this are dropped: too generic to make a match meaningful.
20
+ const MIN_TERM_LEN = 4;
21
+ // Cap on how much failure text is scanned for terms. A tool can dump a
22
+ // multi-megabyte stderr/stdout; the salient error words are always near the
23
+ // front, so we bound the input BEFORE lowercasing/splitting it — otherwise a
24
+ // giant blob would materialise a giant token array (a CPU/memory spike) even
25
+ // though the term COUNT is capped. Generous enough to never clip a real error.
26
+ const MAX_SCAN_CHARS = 4096;
8
27
 
9
- // Read lessons narrow-to-broad through the store and merge; more specific
10
- // scope wins on key. Any per-scope failure is skipped (memory is best-effort).
28
+ // Read lessons narrow-to-broad through the store and resolve cross-scope
29
+ // precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
30
+ // more-specific-wins merge `tree` renders) — so the hook and `tree` provably
31
+ // can't drift. Any per-scope failure is skipped (memory is best-effort).
11
32
  export async function fetchLessons(store, cwd) {
12
33
  const scope = deriveScope(cwd);
13
- const byKey = new Map();
34
+ const groups = [];
14
35
  for (const s of scope.readOrder) {
15
36
  const res = await store.list({ scope: s, limit: 25 });
16
- if (!res || !res.ok) continue;
17
- const entries = Array.isArray(res.entries) ? res.entries : [];
18
- for (const e of entries) {
19
- if (e && e.key && !byKey.has(e.key)) byKey.set(e.key, { ...e, scope: s });
20
- }
37
+ if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
38
+ const entries = (Array.isArray(res.entries) ? res.entries : [])
39
+ .filter((e) => e && e.key)
40
+ .map((e) => ({ ...e, scope: s }));
41
+ groups.push({ scope: s, error: null, entries });
21
42
  }
22
- return { scope, lessons: [...byKey.values()].slice(0, MAX_LESSONS) };
43
+ // First value per key wins (most-specific scope, since `readOrder` is
44
+ // narrow→broad) — exactly what the old inline `byKey` merge did, now via the
45
+ // one shared resolver. The winners, in group order, are the injected set.
46
+ const { groups: resolved } = resolvePrecedence({ groups });
47
+ const lessons = [];
48
+ for (const g of resolved) {
49
+ for (const e of g.entries) if (e.winning) lessons.push(e);
50
+ }
51
+ return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
23
52
  }
24
53
 
25
54
  // Render lessons as a compact markdown block, or null when there are none.
@@ -37,6 +66,60 @@ export function formatLessons(lessons, scope) {
37
66
  return `${header}\n${body}`;
38
67
  }
39
68
 
69
+ // Distil a small set of significant, lowercased search TERMS from a tool
70
+ // failure — the tool name plus the salient words of its error text — used to
71
+ // look up lessons that might already cover this failure. Pure and total: any
72
+ // shape of `toolResponse` (object, string, null, malformed) is handled without
73
+ // throwing. Terms are de-duplicated, stopword- and length-filtered so a match
74
+ // stays meaningful, and the count is capped (`MAX_TERMS`) so a huge error blob
75
+ // can't blow up the downstream scan.
76
+ export function failureQuery(toolName, toolResponse) {
77
+ const text = `${toolName ? String(toolName) : ''} ${errorText(toolResponse)}`.slice(0, MAX_SCAN_CHARS);
78
+ const seen = new Set();
79
+ const terms = [];
80
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
81
+ if (raw.length < MIN_TERM_LEN || STOPWORDS.has(raw) || seen.has(raw)) continue;
82
+ seen.add(raw);
83
+ terms.push(raw);
84
+ if (terms.length >= MAX_TERMS) break;
85
+ }
86
+ return terms;
87
+ }
88
+
89
+ // Lessons whose key OR value literally contains ANY of the failure `terms`
90
+ // (case-insensitive, via the shared `matchesQuery` — never a regex), capped at
91
+ // `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
92
+ // falls back to the write-nudge alone). Preserves `lessons` order, so the
93
+ // most-specific scope's relevant lesson surfaces first.
94
+ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
95
+ if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
96
+ return [];
97
+ }
98
+ const out = [];
99
+ for (const l of lessons) {
100
+ if (terms.some((t) => matchesQuery(l, t))) out.push(l);
101
+ if (out.length >= cap) break;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // 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`.
109
+ export function formatRelevantLessons(lessons) {
110
+ if (!lessons || lessons.length === 0) return null;
111
+ const header =
112
+ `LoreKit — you've hit something like this before. ${lessons.length} related ` +
113
+ 'lesson(s) (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');
120
+ return `${header}\n${body}`;
121
+ }
122
+
40
123
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
41
124
  export function retrospectiveNudge(scope) {
42
125
  const writeScope = scope.repoScope || 'global';
@@ -58,3 +141,64 @@ export function failureNudge(toolName, scope) {
58
141
  `lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
59
142
  );
60
143
  }
144
+
145
+ // Pull the human-readable error text out of a tool_response of any shape. Total:
146
+ // null/primitive/string/object all yield a (possibly empty) string, never a
147
+ // throw — the relevance lookup is best-effort and must not break the host.
148
+ function errorText(response) {
149
+ if (response == null) return '';
150
+ if (typeof response === 'string') return response;
151
+ if (typeof response !== 'object') return String(response);
152
+ const parts = [];
153
+ for (const f of TEXT_FIELDS) {
154
+ const v = response[f];
155
+ if (typeof v === 'string') parts.push(v);
156
+ else if (v && typeof v === 'object') parts.push(safeStringify(v));
157
+ }
158
+ return parts.length ? parts.join(' ') : safeStringify(response);
159
+ }
160
+
161
+ function safeStringify(v) {
162
+ try {
163
+ return JSON.stringify(v) || '';
164
+ } catch {
165
+ return '';
166
+ }
167
+ }
168
+
169
+ // The tool_response fields that commonly carry error text, across frameworks.
170
+ const TEXT_FIELDS = [
171
+ 'stderr',
172
+ 'error',
173
+ 'message',
174
+ 'stdout',
175
+ 'output',
176
+ 'content',
177
+ 'reason',
178
+ 'detail',
179
+ 'details',
180
+ ];
181
+
182
+ // Generic words dropped from a failure query: too common to make a lesson match
183
+ // meaningful (they'd match almost anything). Kept small and hand-picked.
184
+ const STOPWORDS = new Set([
185
+ 'error',
186
+ 'errors',
187
+ 'failed',
188
+ 'failure',
189
+ 'tool',
190
+ 'call',
191
+ 'code',
192
+ 'exit',
193
+ 'with',
194
+ 'this',
195
+ 'that',
196
+ 'from',
197
+ 'null',
198
+ 'true',
199
+ 'false',
200
+ 'undefined',
201
+ 'command',
202
+ 'response',
203
+ 'status',
204
+ ]);
package/src/dedupe.mjs ADDED
@@ -0,0 +1,184 @@
1
+ // `lorekit dedupe` — detect likely-duplicate lessons across the applicable
2
+ // scopes within each store. ZERO-DEP means a HEURISTIC, never embeddings: it
3
+ // clusters lessons whose values share a high Jaccard word-token overlap (default
4
+ // 0.8, tunable with `--threshold`). It flags candidates worth a human's eye — it
5
+ // is NOT a semantic judge and can both miss paraphrases and group coincidental
6
+ // overlaps. The pure core (`tokenize` / `similarity` / `clusterDuplicates`) lives
7
+ // in `lessons-view.mjs` and is thoroughly unit-tested.
8
+ //
9
+ // Clustering is per-store, across all applicable scopes (an offline cluster may
10
+ // span project + global; cross-STORE divergence is `diff`'s job, not this one).
11
+ // Same Offline / Remote split and graceful degradation as `list`. Read-only.
12
+ // Human-facing, so the bin wraps it in `traceCommand`.
13
+ import process from 'node:process';
14
+ import { resolveProjectRoot } from './config.mjs';
15
+ import { deriveScope } from './scope.mjs';
16
+ import { resolveDenies } from './control.mjs';
17
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
+ import { scopeList, gather, clusterDuplicates } from './lessons-view.mjs';
19
+ import { log, heading, status, c } from './util.mjs';
20
+
21
+ const DEFAULT_THRESHOLD = 0.8;
22
+
23
+ // Parse `--threshold` into a number in [0, 1]; anything unparseable or out of
24
+ // range falls back to the default (never a crash on bad input). Pure-ish helper.
25
+ export function parseThreshold(raw) {
26
+ if (raw === undefined || raw === true) return DEFAULT_THRESHOLD;
27
+ const n = Number(raw);
28
+ if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
29
+ return Math.min(1, Math.max(0, n));
30
+ }
31
+
32
+ // Flatten a `gather()` result into one entry list (each entry keeps its scope)
33
+ // for cross-scope clustering, plus the scopes whose read errored (they can't be
34
+ // clustered and are surfaced, not silently dropped).
35
+ function flatten(gathered) {
36
+ const entries = [];
37
+ const errored = [];
38
+ for (const g of gathered.groups || []) {
39
+ if (g.error) {
40
+ errored.push({ scope: g.scope, error: g.error });
41
+ continue;
42
+ }
43
+ for (const e of g.entries || []) entries.push(e);
44
+ }
45
+ return { entries, errored };
46
+ }
47
+
48
+ export async function dedupe(args) {
49
+ const root = resolveProjectRoot(args.dir);
50
+ const env = { ...process.env };
51
+ if (args.store) env.LOREKIT_STORE = args.store;
52
+
53
+ const threshold = parseThreshold(args.threshold);
54
+ const scopeInfo = deriveScope(root);
55
+ // Default to every applicable scope; `--scope <s>` narrows to one.
56
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
57
+
58
+ const { local, remote, connection } = resolveStores(root, {
59
+ env,
60
+ endpoint: args.endpoint,
61
+ token: args.token,
62
+ });
63
+
64
+ // Deny-wins section suppression, identical to the other read commands.
65
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
66
+
67
+ const buildSection = (flat) => ({
68
+ available: true,
69
+ clusters: clusterDuplicates(flat.entries, threshold),
70
+ errored: flat.errored,
71
+ });
72
+
73
+ const offlineSection = localDenied
74
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
75
+ : buildSection(flatten(await gather(local, scopes)));
76
+
77
+ const remoteAvailable = !remoteDenied && remote.usable();
78
+ const remoteSection = remoteAvailable
79
+ ? buildSection(flatten(await gather(remote, scopes)))
80
+ : {
81
+ available: false,
82
+ reason: remoteDenied
83
+ ? `disabled by deny constraint (${remoteDenied.source})`
84
+ : remoteUnavailableReason(connection),
85
+ };
86
+
87
+ const offlineClusters = offlineSection.available ? offlineSection.clusters.length : 0;
88
+ const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
89
+
90
+ if (args.json) {
91
+ log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
92
+ } else {
93
+ heading('LoreKit dedupe');
94
+ log(` project: ${c.dim(root)}`);
95
+ log(` scopes: ${scopes.join(' → ')}`);
96
+ log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
97
+
98
+ renderDedupeSection({ title: 'Offline' }, offlineSection);
99
+ renderDedupeSection(
100
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
101
+ remoteSection,
102
+ );
103
+
104
+ log('');
105
+ const total = offlineClusters + remoteClusters;
106
+ if (total === 0) {
107
+ log(` ${c.green('✓')} no likely-duplicate clusters at this threshold`);
108
+ } else {
109
+ const plural = total === 1 ? '' : 's';
110
+ log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
111
+ }
112
+ log('');
113
+ }
114
+
115
+ // Bounded, non-PII telemetry extras — counts + a boolean, never a scope
116
+ // string, key, path, or token.
117
+ return {
118
+ exitCode: 0,
119
+ 'lorekit.cli.dedupe.scope_count': scopes.length,
120
+ 'lorekit.cli.dedupe.threshold': threshold,
121
+ 'lorekit.cli.dedupe.offline_clusters': offlineClusters,
122
+ 'lorekit.cli.dedupe.remote_clusters': remoteClusters,
123
+ 'lorekit.cli.dedupe.remote_available': remoteAvailable,
124
+ };
125
+ }
126
+
127
+ // Render one store's clusters: each cluster lists its member scope::key lines
128
+ // and a similarity signal. A read error (a scope that couldn't be gathered) is
129
+ // surfaced up front so a partial read is never mistaken for "no duplicates".
130
+ function renderDedupeSection(header, section) {
131
+ heading(header.title);
132
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
133
+
134
+ if (!section.available) {
135
+ status('warn', 'unavailable', section.reason);
136
+ return;
137
+ }
138
+
139
+ for (const e of section.errored || []) {
140
+ log(` ${c.bold(e.scope)} ${c.yellow('!')} ${c.dim(e.error)}`);
141
+ }
142
+
143
+ if (!section.clusters.length) {
144
+ if (!(section.errored || []).length) log(` ${c.dim('no likely-duplicate clusters')}`);
145
+ return;
146
+ }
147
+
148
+ let n = 0;
149
+ for (const cluster of section.clusters) {
150
+ n += 1;
151
+ const range =
152
+ cluster.minSimilarity === cluster.maxSimilarity
153
+ ? cluster.minSimilarity.toFixed(2)
154
+ : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
155
+ log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} lessons, similarity ${range})`)}`);
156
+ for (const m of cluster.members) {
157
+ log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ // The `--json` payload: `{ root, scopes, threshold, offline, remote }` — each
163
+ // store a `{ available, clusters: [{ members, size, minSimilarity,
164
+ // maxSimilarity }], errored }` record (or an unavailable note).
165
+ function buildJson({ root, scopes, threshold, offlineSection, remoteSection }) {
166
+ return {
167
+ root,
168
+ scopes,
169
+ threshold,
170
+ offline: sectionJson(offlineSection),
171
+ remote: sectionJson(remoteSection),
172
+ };
173
+ }
174
+
175
+ function sectionJson(section) {
176
+ if (!section.available) {
177
+ return { available: false, reason: section.reason, clusters: [], errored: [] };
178
+ }
179
+ return {
180
+ available: true,
181
+ clusters: section.clusters,
182
+ errored: section.errored || [],
183
+ };
184
+ }
package/src/hook.mjs CHANGED
@@ -7,7 +7,15 @@ import { resolveProjectRoot } from './config.mjs';
7
7
  import { deriveScope } from './scope.mjs';
8
8
  import { loadControl } from './control.mjs';
9
9
  import { createStore } from './store/index.mjs';
10
- import { fetchLessons, formatLessons, retrospectiveNudge, failureNudge } from './core/lessons.mjs';
10
+ import {
11
+ fetchLessons,
12
+ formatLessons,
13
+ retrospectiveNudge,
14
+ failureNudge,
15
+ failureQuery,
16
+ relevantLessons,
17
+ formatRelevantLessons,
18
+ } from './core/lessons.mjs';
11
19
  import { isFailure } from './core/failure.mjs';
12
20
  import { firstTimeThisSession } from './core/state.mjs';
13
21
  import { recordFixture } from './core/record.mjs';
@@ -92,7 +100,22 @@ async function run(args) {
92
100
  const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
93
101
  if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
94
102
  if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
95
- emit(failureNudge(parsed.toolName, scope));
103
+ // Best-effort: surface any existing lessons that look relevant to THIS
104
+ // failure ("you've hit this before"), then the write-nudge. A missing /
105
+ // unusable store, or no match, silently falls back to the nudge alone.
106
+ let relevant = null;
107
+ try {
108
+ const store = createStore(control);
109
+ if (store) {
110
+ const { lessons } = await fetchLessons(store, root);
111
+ const terms = failureQuery(parsed.toolName, parsed.toolResponse);
112
+ relevant = formatRelevantLessons(relevantLessons(lessons, terms));
113
+ }
114
+ } catch {
115
+ relevant = null; // never let a lesson lookup break the failure nudge
116
+ }
117
+ const nudge = failureNudge(parsed.toolName, scope);
118
+ emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
96
119
  return 0;
97
120
  }
98
121
 
@@ -0,0 +1,65 @@
1
+ // Pure, DEPENDENCY-FREE lesson primitives shared by the hook engine
2
+ // (`core/lessons.mjs`, the hot path) and the read-command view layer
3
+ // (`lessons-view.mjs`, which re-exports these). Kept in its own module so the
4
+ // hook can import the exact precedence + match logic the read commands use —
5
+ // one source of truth, no drift — WITHOUT dragging in the rendering/`util`
6
+ // stack (`heading`/`log`/`c`, plus the lint/dedupe/diff cores) that the rest of
7
+ // `lessons-view.mjs` carries. Zero imports on purpose.
8
+
9
+ // ── cross-scope precedence resolution (the `tree` + hook merge core) ──────────
10
+
11
+ // Given per-scope groups in RESOLUTION ORDER — most-specific first, exactly the
12
+ // order `deriveScope().readOrder` produces — compute which scope's lesson WINS
13
+ // each key and which are shadowed. This is the hook engine's merge: it iterates
14
+ // the scopes narrow-to-broad and keeps the FIRST value seen per key (`if
15
+ // (!winnerScopeByKey.has(key)) set`), so a more-specific scope shadows a broader
16
+ // scope's same-key lesson. Returns the same groups with every entry tagged
17
+ // `{ winning, shadowedBy }` (`shadowedBy` = the scope that won the key, or null
18
+ // for a winner), plus the resolved `winners` list (`{ scope, key }`, one per
19
+ // key) and winning/shadowed counts. A per-scope read error is passed through
20
+ // untouched (empty entries), so one unreadable scope never derails resolution.
21
+ // Pure — consumed by both `tree` (display) and `fetchLessons` (injection).
22
+ export function resolvePrecedence({ groups = [] } = {}) {
23
+ const winnerScopeByKey = new Map(); // key → the scope that first claimed it
24
+ const outGroups = [];
25
+ let winningTotal = 0;
26
+ let shadowedTotal = 0;
27
+ for (const g of groups) {
28
+ if (g.error) {
29
+ outGroups.push({ scope: g.scope, error: g.error, entries: [] });
30
+ continue;
31
+ }
32
+ const entries = [];
33
+ for (const e of g.entries || []) {
34
+ const prior = winnerScopeByKey.get(e.key);
35
+ if (prior === undefined) {
36
+ winnerScopeByKey.set(e.key, g.scope);
37
+ entries.push({ ...e, winning: true, shadowedBy: null });
38
+ winningTotal += 1;
39
+ } else {
40
+ entries.push({ ...e, winning: false, shadowedBy: prior });
41
+ shadowedTotal += 1;
42
+ }
43
+ }
44
+ outGroups.push({ scope: g.scope, error: null, entries });
45
+ }
46
+ const winners = [...winnerScopeByKey.entries()].map(([key, scope]) => ({ scope, key }));
47
+ return { groups: outGroups, winners, winningTotal, shadowedTotal };
48
+ }
49
+
50
+ // ── literal, case-insensitive substring matching (the `search` + hook core) ───
51
+
52
+ // Case-insensitive, LITERAL substring match of `query` against a normalized
53
+ // entry's key OR value — the `search` command's matcher, and the hook's
54
+ // failure-relevance matcher. Deliberately a plain `String.includes` (never
55
+ // `new RegExp(query)`) so a query full of regex metacharacters like `a.*(b)`
56
+ // matches those characters verbatim, never as a pattern. An empty query matches
57
+ // everything (the command guards emptiness as a usage error before ever calling
58
+ // this). Pure — trivially unit-testable.
59
+ export function matchesQuery(entry, query) {
60
+ const needle = String(query == null ? '' : query).toLowerCase();
61
+ if (!needle) return true;
62
+ const key = String(entry?.key ?? '').toLowerCase();
63
+ const value = String(entry?.value ?? '').toLowerCase();
64
+ return key.includes(needle) || value.includes(needle);
65
+ }
Binary file
package/src/lint.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // `lorekit lint` — flag low-quality lessons across the applicable scopes and
2
+ // both stores. Each finding names the rule it violated (empty/whitespace value,
3
+ // suspiciously short value, untrimmed value, empty key, malformed scope). The
4
+ // rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` / `lintEntry`),
5
+ // each independently unit-tested.
6
+ //
7
+ // Exit convention: `lint` exits NON-ZERO (1) when any finding exists, so it is
8
+ // usable as a CI gate (`lorekit lint || fail`); a clean run — or a run where the
9
+ // only issue is an unavailable store — exits 0. `--json` carries the structured
10
+ // findings either way. Same Offline / Remote split and graceful degradation as
11
+ // `list`; a `LOREKIT_DENY` ceiling or an unconfigured remote is a note, not an
12
+ // error. Read-only. Human-facing, so the bin wraps it in `traceCommand`.
13
+ import process from 'node:process';
14
+ import { resolveProjectRoot } from './config.mjs';
15
+ import { deriveScope } from './scope.mjs';
16
+ import { resolveDenies } from './control.mjs';
17
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
+ import { scopeList, gather, lintGroups } from './lessons-view.mjs';
19
+ import { log, heading, status, c } from './util.mjs';
20
+
21
+ export async function lint(args) {
22
+ const root = resolveProjectRoot(args.dir);
23
+ const env = { ...process.env };
24
+ if (args.store) env.LOREKIT_STORE = args.store;
25
+
26
+ const scopeInfo = deriveScope(root);
27
+ // Default to every applicable scope; `--scope <s>` narrows to one.
28
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
29
+
30
+ const { local, remote, connection } = resolveStores(root, {
31
+ env,
32
+ endpoint: args.endpoint,
33
+ token: args.token,
34
+ });
35
+
36
+ // Deny-wins section suppression, identical to the other read commands.
37
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
38
+
39
+ const offlineResult = localDenied ? { groups: [], total: 0 } : lintGroups(await gather(local, scopes));
40
+ const remoteAvailable = !remoteDenied && remote.usable();
41
+ const remoteResult = remoteAvailable ? lintGroups(await gather(remote, scopes)) : { groups: [], total: 0 };
42
+
43
+ const offlineSection = localDenied
44
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
45
+ : { available: true, ...offlineResult };
46
+ const remoteSection = remoteAvailable
47
+ ? { available: true, ...remoteResult }
48
+ : {
49
+ available: false,
50
+ reason: remoteDenied
51
+ ? `disabled by deny constraint (${remoteDenied.source})`
52
+ : remoteUnavailableReason(connection),
53
+ };
54
+
55
+ const totalFindings =
56
+ (offlineSection.available ? offlineSection.total : 0) +
57
+ (remoteSection.available ? remoteSection.total : 0);
58
+
59
+ if (args.json) {
60
+ log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection, totalFindings }), null, 2));
61
+ } else {
62
+ heading('LoreKit lint');
63
+ log(` project: ${c.dim(root)}`);
64
+ log(` scopes: ${scopes.join(' → ')}`);
65
+
66
+ renderLintSection({ title: 'Offline' }, offlineSection);
67
+ renderLintSection(
68
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
69
+ remoteSection,
70
+ );
71
+
72
+ log('');
73
+ if (totalFindings === 0) {
74
+ log(` ${c.green('✓')} no lint issues in the applicable scopes`);
75
+ } else {
76
+ const plural = totalFindings === 1 ? '' : 's';
77
+ log(` ${c.yellow('!')} ${totalFindings} lint issue${plural} found`);
78
+ }
79
+ log('');
80
+ }
81
+
82
+ // Exit non-zero when findings exist so `lint` is usable as a CI gate. An
83
+ // unavailable store is never itself a failure — only actual findings are.
84
+ // Bounded, non-PII telemetry extras — counts + a boolean.
85
+ return {
86
+ exitCode: totalFindings > 0 ? 1 : 0,
87
+ 'lorekit.cli.lint.scope_count': scopes.length,
88
+ 'lorekit.cli.lint.offline_findings': offlineSection.available ? offlineSection.total : 0,
89
+ 'lorekit.cli.lint.remote_findings': remoteSection.available ? remoteSection.total : 0,
90
+ 'lorekit.cli.lint.total_findings': totalFindings,
91
+ 'lorekit.cli.lint.remote_available': remoteAvailable,
92
+ };
93
+ }
94
+
95
+ // Render one store's findings, grouped by scope: `key rule — message` per
96
+ // finding. A scope with no findings and no error is omitted; a read error is
97
+ // surfaced in place (its entries couldn't be linted).
98
+ function renderLintSection(header, section) {
99
+ heading(header.title);
100
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
101
+
102
+ if (!section.available) {
103
+ status('warn', 'unavailable', section.reason);
104
+ return;
105
+ }
106
+
107
+ const printable = (section.groups || []).filter((g) => g.findings.length || g.error);
108
+ if (!printable.length) {
109
+ log(` ${c.dim('no lint issues in the applicable scopes')}`);
110
+ return;
111
+ }
112
+
113
+ for (const g of printable) {
114
+ log(` ${c.bold(g.scope)}`);
115
+ if (g.error) {
116
+ log(` ${c.yellow('!')} ${c.dim(g.error)}`);
117
+ continue;
118
+ }
119
+ for (const f of g.findings) {
120
+ log(` ${c.yellow('•')} ${f.key} ${c.dim(`[${f.rule}]`)} ${f.message}`);
121
+ }
122
+ }
123
+ }
124
+
125
+ // The `--json` payload: `{ root, scopes, total, offline, remote }` — each store
126
+ // a `{ available, total, scopes: [{ scope, error, findings: [{ key, rule,
127
+ // message }] }] }` record (or an unavailable note), so a script gets the same
128
+ // structured finding list regardless of which store it came from.
129
+ function buildJson({ root, scopes, offlineSection, remoteSection, totalFindings }) {
130
+ return {
131
+ root,
132
+ scopes,
133
+ total: totalFindings,
134
+ offline: sectionJson(offlineSection),
135
+ remote: sectionJson(remoteSection),
136
+ };
137
+ }
138
+
139
+ function sectionJson(section) {
140
+ if (!section.available) {
141
+ return { available: false, reason: section.reason, total: 0, scopes: [] };
142
+ }
143
+ return {
144
+ available: true,
145
+ total: section.total,
146
+ scopes: (section.groups || []).map((g) => ({
147
+ scope: g.scope,
148
+ error: g.error || null,
149
+ findings: g.findings,
150
+ })),
151
+ };
152
+ }
package/src/scope.mjs CHANGED
@@ -44,7 +44,11 @@ export function deriveScope(cwd = process.cwd()) {
44
44
  : null;
45
45
  const projectScope = `project::${projectName}`;
46
46
 
47
- const readOrder = [branchScope, repoScope, 'global'].filter(Boolean);
47
+ // Injection / resolution order, most-specific → broadest. Matches the read
48
+ // commands' `scopeList` exactly (`lessons-view.mjs`) — project is FIRST, so a
49
+ // project-scoped lesson wins and IS injected. De-duplicated (a project whose
50
+ // basename collides with a scope, or a repo with no branch, never repeats).
51
+ const readOrder = [...new Set([projectScope, branchScope, repoScope, 'global'].filter(Boolean))];
48
52
 
49
53
  return {
50
54
  ownerRepo,
package/src/telemetry.mjs CHANGED
@@ -4,9 +4,9 @@
4
4
  // mirrors the Edge Function's SDK-free approach (supabase/functions/_shared/
5
5
  // otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
6
6
  // packages. One span + one counter data point per human-facing command
7
- // (install / uninstall / doctor / list / search / show / stats / diff /
8
- // migrate), fired to Dash0 so the maintainers can see which commands people
9
- // actually run.
7
+ // (install / uninstall / doctor / list / search / show / stats / diff / tree /
8
+ // lint / dedupe / migrate), fired to Dash0 so the maintainers can see which
9
+ // commands people actually run.
10
10
  //
11
11
  // Privacy — this runs on end-users' machines, so it is deliberately narrow:
12
12
  // • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
@@ -275,7 +275,7 @@ function normalizeExitCode(result) {
275
275
  * counter point. Returns the command's exit code unchanged. Telemetry failures
276
276
  * are swallowed — the command result is never affected.
277
277
  *
278
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | migrate
278
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
279
279
  * @param {object} args parsed CLI args (read for allow-listed flags only)
280
280
  * @param {string} version CLI version (from package.json)
281
281
  * @param {() => Promise<number>} run the command handler
package/src/tree.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // `lorekit tree` (alias `resolve`) — show the applicable scopes as a precedence
2
+ // hierarchy and mark, for any key that lives at MORE THAN ONE scope, which
3
+ // scope's lesson actually WINS and which are shadowed. This answers "which
4
+ // lesson applies here, and what is being overridden?".
5
+ //
6
+ // Precedence is not an assumption — it mirrors the hook engine exactly. The
7
+ // SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
8
+ // `deriveScope().readOrder` (project → branch → repo → global, most-specific
9
+ // first) and keeps the FIRST value seen per key, so a more-specific scope
10
+ // shadows a broader scope's same-key lesson. `tree` resolves over that same
11
+ // `readOrder` set via the pure `resolvePrecedence`, so it shows the same
12
+ // resolution order the agent is injected with (the hook additionally caps the
13
+ // injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
14
+ // more winners than the hook injects).
15
+ //
16
+ // NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
17
+ // PR it INCLUDES `project::` (project is the most-specific scope and now wins /
18
+ // is injected), so `tree` shows it too — the ordering is now unified across the
19
+ // hook, `tree`, and every read command's `scopeList`. Both stores are resolved
20
+ // independently (precedence is per-store — the hook reads one resolved store),
21
+ // in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
22
+ // `traceCommand` by the bin.
23
+ import process from 'node:process';
24
+ import { resolveProjectRoot } from './config.mjs';
25
+ import { deriveScope } from './scope.mjs';
26
+ import { resolveDenies } from './control.mjs';
27
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
28
+ import { resolvePrecedence } from './lessons-pure.mjs';
29
+ import { gather, preview, shortDate } from './lessons-view.mjs';
30
+ import { log, heading, status, c } from './util.mjs';
31
+
32
+ export async function tree(args) {
33
+ const root = resolveProjectRoot(args.dir);
34
+ const env = { ...process.env };
35
+ if (args.store) env.LOREKIT_STORE = args.store;
36
+
37
+ const scopeInfo = deriveScope(root);
38
+ // Default to the injected resolution set (`readOrder`, most-specific first);
39
+ // `--scope <s>` narrows to one (honored even outside the set — the user asked).
40
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
41
+
42
+ const { local, remote, connection } = resolveStores(root, {
43
+ env,
44
+ endpoint: args.endpoint,
45
+ token: args.token,
46
+ });
47
+
48
+ // Deny-wins section suppression, identical to the other read commands.
49
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
50
+
51
+ const offlineResolved = localDenied ? null : resolvePrecedence(await gather(local, scopes));
52
+ const remoteAvailable = !remoteDenied && remote.usable();
53
+ const remoteResolved = remoteAvailable ? resolvePrecedence(await gather(remote, scopes)) : null;
54
+
55
+ const offlineSection = localDenied
56
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
57
+ : { available: true, ...offlineResolved };
58
+ const remoteSection = remoteAvailable
59
+ ? { available: true, ...remoteResolved }
60
+ : {
61
+ available: false,
62
+ reason: remoteDenied
63
+ ? `disabled by deny constraint (${remoteDenied.source})`
64
+ : remoteUnavailableReason(connection),
65
+ };
66
+
67
+ if (args.json) {
68
+ log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
69
+ } else {
70
+ heading('LoreKit resolution tree');
71
+ log(` project: ${c.dim(root)}`);
72
+ log(` scopes: ${scopes.join(' → ')}`);
73
+ log(` ${c.dim('precedence order (most-specific first); a more-specific scope wins a duplicate key')}`);
74
+
75
+ renderTreeSection({ title: 'Offline' }, offlineSection);
76
+ renderTreeSection(
77
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
78
+ remoteSection,
79
+ );
80
+
81
+ log('');
82
+ }
83
+
84
+ // Bounded, non-PII telemetry extras — counts + a boolean, never a scope
85
+ // string, key, path, or token.
86
+ return {
87
+ exitCode: 0,
88
+ 'lorekit.cli.tree.scope_count': scopes.length,
89
+ 'lorekit.cli.tree.offline_winning': offlineSection.available ? offlineSection.winningTotal : 0,
90
+ 'lorekit.cli.tree.offline_shadowed': offlineSection.available ? offlineSection.shadowedTotal : 0,
91
+ 'lorekit.cli.tree.remote_shadowed': remoteSection.available ? remoteSection.shadowedTotal : 0,
92
+ 'lorekit.cli.tree.remote_available': remoteAvailable,
93
+ };
94
+ }
95
+
96
+ // Render one store's resolution: each scope in precedence order with its entries
97
+ // tagged winning (✓) or shadowed (↳ shadowed by <scope>). A scope with no
98
+ // entries and no error is omitted; a read error is surfaced in place.
99
+ function renderTreeSection(header, section) {
100
+ heading(header.title);
101
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
102
+
103
+ if (!section.available) {
104
+ status('warn', 'unavailable', section.reason);
105
+ return;
106
+ }
107
+
108
+ const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
109
+ if (!printable.length) {
110
+ log(` ${c.dim('no lessons found in the applicable scopes')}`);
111
+ return;
112
+ }
113
+
114
+ for (const g of printable) {
115
+ log(` ${c.bold(g.scope)}`);
116
+ if (g.error) {
117
+ log(` ${c.yellow('!')} ${c.dim(g.error)}`);
118
+ continue;
119
+ }
120
+ for (const e of g.entries) {
121
+ const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
122
+ const mark = e.winning ? c.green('✓') : c.yellow('↳');
123
+ const tag = e.winning ? '' : ` ${c.dim(`shadowed by ${e.shadowedBy}`)}`;
124
+ log(` ${mark} ${e.key}${tag}${when}`);
125
+ if (e.value) log(` ${c.dim(preview(e.value))}`);
126
+ }
127
+ }
128
+
129
+ log(
130
+ ` ${c.dim(`${section.winningTotal} winning, ${section.shadowedTotal} shadowed`)}`,
131
+ );
132
+ }
133
+
134
+ // The `--json` payload: per-section resolved groups (each entry carrying its
135
+ // `winning` / `shadowedBy` tags), the flat `winners` list, and the counts — so a
136
+ // script gets the resolution verdict directly, in the same shape per store.
137
+ function buildJson({ root, scopes, offlineSection, remoteSection }) {
138
+ return {
139
+ root,
140
+ scopes,
141
+ offline: sectionJson(offlineSection),
142
+ remote: sectionJson(remoteSection),
143
+ };
144
+ }
145
+
146
+ function sectionJson(section) {
147
+ if (!section.available) {
148
+ return { available: false, reason: section.reason, winners: [], groups: [] };
149
+ }
150
+ return {
151
+ available: true,
152
+ winningTotal: section.winningTotal,
153
+ shadowedTotal: section.shadowedTotal,
154
+ winners: section.winners,
155
+ groups: section.groups,
156
+ };
157
+ }