@lorekit/cli 1.9.0 → 1.11.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 +99 -3
- package/bin/lorekit.mjs +158 -5
- package/package.json +1 -1
- package/src/control.mjs +13 -0
- package/src/dedupe.mjs +184 -0
- package/src/diff.mjs +180 -0
- package/src/lessons-view.mjs +0 -0
- package/src/lint.mjs +152 -0
- package/src/list.mjs +2 -5
- package/src/search.mjs +2 -5
- package/src/show.mjs +2 -5
- package/src/stats.mjs +142 -0
- package/src/telemetry.mjs +4 -3
- package/src/tree.mjs +155 -0
package/README.md
CHANGED
|
@@ -173,6 +173,101 @@ missing. It exits **non-zero** when the key is found in no readable store, so it
|
|
|
173
173
|
fits scripts. `--json` emits the full normalized record(s) and which store each
|
|
174
174
|
came from. Both a scope and a key are required (else a usage error).
|
|
175
175
|
|
|
176
|
+
### `lorekit stats`
|
|
177
|
+
|
|
178
|
+
An at-a-glance overview of how many lessons apply to the current directory —
|
|
179
|
+
counted **per scope** and **per store** (Offline vs Remote), with per-store and
|
|
180
|
+
grand totals, in the same Offline / Remote split as `list`:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
lorekit stats # per-scope counts + totals for both stores
|
|
184
|
+
lorekit stats --scope global # narrow to a single scope
|
|
185
|
+
lorekit stats --json # { offline, remote } with per-scope { count } rows
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Every applicable scope prints a row (a scope with zero lessons still shows `0`,
|
|
189
|
+
which is the point of an overview). An unconfigured remote degrades to a short
|
|
190
|
+
note — never an error, always exit 0. `--endpoint` / `--token` / `--store`
|
|
191
|
+
behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
|
|
192
|
+
per scope (the server's default page size); there is no cap-usage `N / limit`
|
|
193
|
+
figure because the MCP surface exposes no total-count or cap tool.
|
|
194
|
+
|
|
195
|
+
### `lorekit diff`
|
|
196
|
+
|
|
197
|
+
Compare the **offline** and **remote** stores for the applicable scopes and
|
|
198
|
+
report where they diverge, grouped by scope:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
lorekit diff # local-only / remote-only / conflicting, per scope
|
|
202
|
+
lorekit diff --scope global # narrow to a single scope
|
|
203
|
+
lorekit diff --json # { comparable, totals, groups[] } for scripts
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Three groups: **local-only** (key present offline, absent remote), **remote-only**
|
|
207
|
+
(absent offline, present remote), and **conflicting** (same `scope::key` in both,
|
|
208
|
+
but the value or tags differ). A diff needs **both** stores readable — if the
|
|
209
|
+
remote is unconfigured (or a store is denied), a meaningful diff is impossible,
|
|
210
|
+
so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
|
|
211
|
+
rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
212
|
+
|
|
213
|
+
### `lorekit tree` (alias `resolve`)
|
|
214
|
+
|
|
215
|
+
Show the scopes the hooks actually **inject** — branch → repo → global, in
|
|
216
|
+
precedence order (most-specific first) — as a resolution hierarchy, and mark for
|
|
217
|
+
any key present at more than one scope which scope's lesson **wins** and which are
|
|
218
|
+
**shadowed**:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
|
|
222
|
+
lorekit tree --scope global # narrow to a single scope
|
|
223
|
+
lorekit tree --json # per-entry { winning, shadowedBy } + a winners[] list
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
|
|
227
|
+
in `readOrder` (branch → repo → global) and keeps the first value seen per key, so
|
|
228
|
+
a more-specific scope overrides a broader scope's same-key lesson. It answers
|
|
229
|
+
"which lesson actually applies here, and what is being overridden?". Note that
|
|
230
|
+
`project::` scope is **not** part of the injected set (the hooks never inject
|
|
231
|
+
project lessons), so `tree` doesn't show it — browse those with `lorekit list`.
|
|
232
|
+
Each store is resolved independently, in the same Offline / Remote split.
|
|
233
|
+
|
|
234
|
+
### `lorekit lint`
|
|
235
|
+
|
|
236
|
+
Flag low-quality lessons across the applicable scopes and both stores. Each
|
|
237
|
+
finding names the rule it violated:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
lorekit lint # findings grouped by scope; exits non-zero if any
|
|
241
|
+
lorekit lint --scope global # narrow to a single scope
|
|
242
|
+
lorekit lint --json # { total, offline, remote } structured findings
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
|
|
246
|
+
body below a small length threshold), **untrimmed-value** (real content with
|
|
247
|
+
surrounding whitespace), **empty-key** (blank key), and **malformed-scope** (e.g.
|
|
248
|
+
a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
|
|
249
|
+
is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
|
|
250
|
+
or one where only a store is unavailable — exits 0. The pure rule predicates live
|
|
251
|
+
in `lessons-view.mjs` and are unit-tested one rule at a time.
|
|
252
|
+
|
|
253
|
+
### `lorekit dedupe`
|
|
254
|
+
|
|
255
|
+
Find likely-duplicate lessons and group them into clusters — per store, across
|
|
256
|
+
the applicable scopes:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
lorekit dedupe # clusters of near-duplicate lessons per store
|
|
260
|
+
lorekit dedupe --threshold 0.6 # loosen the similarity cutoff (default 0.8)
|
|
261
|
+
lorekit dedupe --json # { threshold, offline, remote } clusters + signal
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The similarity signal is a zero-dependency **heuristic** — Jaccard overlap of
|
|
265
|
+
lowercased word tokens, **not** a semantic/embedding measure — so it surfaces
|
|
266
|
+
candidates for a human to review and can both miss paraphrases and group
|
|
267
|
+
coincidental overlaps. Any pair scoring at or above `--threshold` links (transitively)
|
|
268
|
+
into one cluster; only clusters of 2+ members are reported, each with a similarity
|
|
269
|
+
range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
|
|
270
|
+
|
|
176
271
|
### `lorekit hook`
|
|
177
272
|
|
|
178
273
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -355,8 +450,9 @@ active deny constraints.
|
|
|
355
450
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
356
451
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
357
452
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
358
|
-
| `--json` | Machine-readable output (`list` / `search` / `show`) |
|
|
359
|
-
| `--scope <scope>` | Restrict to a single scope (`list` / `search`; default: all applicable) |
|
|
453
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff` / `tree` / `lint` / `dedupe`) |
|
|
454
|
+
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable) |
|
|
455
|
+
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
360
456
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
361
457
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
362
458
|
| `-h, --help` | Help |
|
|
@@ -442,7 +538,7 @@ forever. This reduces manual validation to a single capture pass per tool.
|
|
|
442
538
|
## Usage telemetry
|
|
443
539
|
|
|
444
540
|
The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
|
|
445
|
-
`show`, `migrate`)
|
|
541
|
+
`show`, `stats`, `diff`, `migrate`)
|
|
446
542
|
emit one OpenTelemetry span + one counter point per run so the maintainers can see which
|
|
447
543
|
commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
|
|
448
544
|
deliberately narrow — it carries only the command name, a bounded set of boolean
|
package/bin/lorekit.mjs
CHANGED
|
@@ -9,6 +9,11 @@ import { doctor } from '../src/doctor.mjs';
|
|
|
9
9
|
import { list } from '../src/list.mjs';
|
|
10
10
|
import { search } from '../src/search.mjs';
|
|
11
11
|
import { show } from '../src/show.mjs';
|
|
12
|
+
import { stats } from '../src/stats.mjs';
|
|
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';
|
|
12
17
|
import { hook } from '../src/hook.mjs';
|
|
13
18
|
import { migrate } from '../src/migrate.mjs';
|
|
14
19
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
@@ -48,6 +53,21 @@ ${c.bold('Commands')}
|
|
|
48
53
|
show Inspect one lesson in full: its complete value, scope, key, updated
|
|
49
54
|
date, tags, and which store(s) it lives in (noting any divergence
|
|
50
55
|
when it is in both). --json. Usage: show <scope> <key>.
|
|
56
|
+
stats Count the applicable lessons per scope and per store (offline vs
|
|
57
|
+
remote), with per-store and grand totals, in the same Offline/
|
|
58
|
+
Remote split. --json, --scope <s>.
|
|
59
|
+
diff Compare the offline and remote stores for the applicable scopes and
|
|
60
|
+
report divergence: local-only, remote-only, and conflicting keys
|
|
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>.
|
|
51
71
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
52
72
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
53
73
|
hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
|
|
@@ -66,8 +86,9 @@ ${c.bold('Options')}
|
|
|
66
86
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
67
87
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
68
88
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
69
|
-
--json Machine-readable output (list / search / show)
|
|
70
|
-
--scope <scope> Restrict to a single scope (list / search)
|
|
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)
|
|
71
92
|
--from <path> Source store to migrate from (migrate)
|
|
72
93
|
--to <tier> Migration destination tier: home | project (migrate;
|
|
73
94
|
default routes each entry by scope)
|
|
@@ -240,6 +261,127 @@ ${c.bold('Options')}
|
|
|
240
261
|
${c.bold('Examples')}
|
|
241
262
|
npx @lorekit/cli show global prefer-guard-clauses
|
|
242
263
|
npx @lorekit/cli show project::widget build-flags --json
|
|
264
|
+
`,
|
|
265
|
+
stats: `${c.bold('lorekit stats')} — count the applicable lessons per scope and per store
|
|
266
|
+
|
|
267
|
+
${c.bold('Usage')}
|
|
268
|
+
npx @lorekit/cli stats [options]
|
|
269
|
+
|
|
270
|
+
Shows how many lessons apply to the current directory's scopes (project/branch/
|
|
271
|
+
repo/global), broken down per scope and per store (Offline = the local .lorekit/
|
|
272
|
+
+ ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
|
|
273
|
+
grand totals. An unconfigured remote degrades to a short note, never an error.
|
|
274
|
+
|
|
275
|
+
${c.bold('Options')}
|
|
276
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
277
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
278
|
+
--json Machine-readable output
|
|
279
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
280
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
281
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
282
|
+
|
|
283
|
+
${c.bold('Examples')}
|
|
284
|
+
npx @lorekit/cli stats
|
|
285
|
+
npx @lorekit/cli stats --json
|
|
286
|
+
npx @lorekit/cli stats --scope global
|
|
287
|
+
`,
|
|
288
|
+
diff: `${c.bold('lorekit diff')} — compare the offline and remote stores
|
|
289
|
+
|
|
290
|
+
${c.bold('Usage')}
|
|
291
|
+
npx @lorekit/cli diff [options]
|
|
292
|
+
|
|
293
|
+
Compares the local (offline) store against the hosted (remote) store for the
|
|
294
|
+
current directory's scopes and reports where they diverge, grouped by scope:
|
|
295
|
+
local-only keys, remote-only keys, and conflicting keys (same key, different
|
|
296
|
+
value or tags). A diff needs BOTH stores readable — if the remote is
|
|
297
|
+
unconfigured or a store is denied, \`diff\` prints a clear note and exits 0.
|
|
298
|
+
|
|
299
|
+
${c.bold('Options')}
|
|
300
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
301
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
302
|
+
--json Machine-readable output
|
|
303
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
304
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
305
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
306
|
+
|
|
307
|
+
${c.bold('Examples')}
|
|
308
|
+
npx @lorekit/cli diff
|
|
309
|
+
npx @lorekit/cli diff --json
|
|
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
|
|
243
385
|
`,
|
|
244
386
|
migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
|
|
245
387
|
|
|
@@ -293,19 +435,20 @@ ${c.bold('Options')}
|
|
|
293
435
|
const KNOWN_FLAGS = [
|
|
294
436
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
295
437
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
296
|
-
'event', 'json', 'scope', 'help', 'version',
|
|
438
|
+
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
297
439
|
];
|
|
298
440
|
|
|
299
441
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
300
442
|
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
301
443
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
302
444
|
const HUMAN_COMMANDS = new Set([
|
|
303
|
-
'install', 'uninstall', 'doctor', 'list', 'search', 'show', '
|
|
445
|
+
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
|
|
446
|
+
'tree', 'lint', 'dedupe', 'migrate',
|
|
304
447
|
]);
|
|
305
448
|
|
|
306
449
|
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
307
450
|
// and telemetry both resolve to the real command name.
|
|
308
|
-
const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
|
|
451
|
+
const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
|
|
309
452
|
|
|
310
453
|
async function main() {
|
|
311
454
|
// Load a `.env` from the current directory (if any) before anything reads the
|
|
@@ -380,6 +523,16 @@ async function main() {
|
|
|
380
523
|
return traceCommand('search', args, VERSION, () => search(args));
|
|
381
524
|
case 'show':
|
|
382
525
|
return traceCommand('show', args, VERSION, () => show(args));
|
|
526
|
+
case 'stats':
|
|
527
|
+
return traceCommand('stats', args, VERSION, () => stats(args));
|
|
528
|
+
case 'diff':
|
|
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));
|
|
383
536
|
case 'migrate':
|
|
384
537
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
385
538
|
default:
|
package/package.json
CHANGED
package/src/control.mjs
CHANGED
|
@@ -124,6 +124,19 @@ export function localStoreDirs(root = process.cwd(), env = process.env) {
|
|
|
124
124
|
return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Resolve the deny-wins ceiling for the read commands: which section (offline /
|
|
128
|
+
// remote) is forbidden outright by an active `deny` constraint. A deny is never
|
|
129
|
+
// overridable (see the module header), so this is the single seam `list`,
|
|
130
|
+
// `search`, `show`, `stats`, and `diff` share instead of each re-deriving the
|
|
131
|
+
// same `control.denies.find(...)` block. Returns the matched deny object
|
|
132
|
+
// ({ mode, source }) or null per side — the `source` explains the "why" in each
|
|
133
|
+
// command's graceful note. Thin wrapper over `loadControl`, co-located with it.
|
|
134
|
+
export function resolveDenies(root, { env = process.env } = {}) {
|
|
135
|
+
const control = loadControl(root, { env });
|
|
136
|
+
const find = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
137
|
+
return { localDenied: find('local'), remoteDenied: find('remote') };
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
// IO wrapper — load env + config files, derive the connection, then resolve.
|
|
128
141
|
export function loadControl(root, { env = process.env } = {}) {
|
|
129
142
|
const home = userConfigDir(env);
|
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/diff.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `lorekit diff` — compare the offline (local two-tier) store against the remote
|
|
2
|
+
// (hosted MCP) store for the current directory's scopes, and report where they
|
|
3
|
+
// diverge in three groups, grouped by scope:
|
|
4
|
+
// • local-only — key present offline, absent remote;
|
|
5
|
+
// • remote-only — absent offline, present remote;
|
|
6
|
+
// • conflicting — same scope::key in both, but the value/tags differ.
|
|
7
|
+
//
|
|
8
|
+
// A diff is only meaningful when BOTH stores are readable. If either is
|
|
9
|
+
// unavailable — the remote unconfigured, a `LOREKIT_DENY` ceiling, etc. — a diff
|
|
10
|
+
// is impossible, so `diff` prints a clear note explaining which store is missing
|
|
11
|
+
// and exits 0 (never a crash). Read-only. Human-facing, so the bin wraps it in
|
|
12
|
+
// `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, diffGroups, preview, shortDate } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, c } from './util.mjs';
|
|
20
|
+
|
|
21
|
+
export async function diff(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 localAvailable = !localDenied;
|
|
40
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
41
|
+
|
|
42
|
+
// Explain, per store, why it is not comparable (used for the not-comparable
|
|
43
|
+
// note below). A store that IS available reports `null`.
|
|
44
|
+
const offlineReason = localDenied
|
|
45
|
+
? `disabled by deny constraint (${localDenied.source})`
|
|
46
|
+
: null;
|
|
47
|
+
const remoteReason = remoteDenied
|
|
48
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
49
|
+
: remoteAvailable
|
|
50
|
+
? null
|
|
51
|
+
: remoteUnavailableReason(connection);
|
|
52
|
+
|
|
53
|
+
const comparable = localAvailable && remoteAvailable;
|
|
54
|
+
|
|
55
|
+
// Only gather (and diff) when both stores are readable — a one-sided read
|
|
56
|
+
// cannot distinguish "only in the other store" from "the other store is
|
|
57
|
+
// simply unreachable".
|
|
58
|
+
const result = comparable ? diffGroups(await gather(local, scopes), await gather(remote, scopes)) : null;
|
|
59
|
+
|
|
60
|
+
if (args.json) {
|
|
61
|
+
log(JSON.stringify(buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }), null, 2));
|
|
62
|
+
} else if (!comparable) {
|
|
63
|
+
renderNotComparable({ root, scopes, offlineReason, remoteReason });
|
|
64
|
+
} else {
|
|
65
|
+
renderDiff({ root, scopes, connection, remoteAvailable, result });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Bounded, non-PII telemetry extras — counts + booleans, never a scope
|
|
69
|
+
// string, key, path, or token.
|
|
70
|
+
const totals = result ? result.totals : { localOnly: 0, remoteOnly: 0, conflicting: 0 };
|
|
71
|
+
return {
|
|
72
|
+
exitCode: 0,
|
|
73
|
+
'lorekit.cli.diff.scope_count': scopes.length,
|
|
74
|
+
'lorekit.cli.diff.comparable': comparable,
|
|
75
|
+
'lorekit.cli.diff.local_only': totals.localOnly,
|
|
76
|
+
'lorekit.cli.diff.remote_only': totals.remoteOnly,
|
|
77
|
+
'lorekit.cli.diff.conflicting': totals.conflicting,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The not-comparable note: which store(s) are missing, and why a diff needs both.
|
|
82
|
+
function renderNotComparable({ root, scopes, offlineReason, remoteReason }) {
|
|
83
|
+
heading('LoreKit diff');
|
|
84
|
+
log(` project: ${c.dim(root)}`);
|
|
85
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
86
|
+
log('');
|
|
87
|
+
if (offlineReason) status('warn', 'offline unavailable', offlineReason);
|
|
88
|
+
if (remoteReason) status('warn', 'remote unavailable', remoteReason);
|
|
89
|
+
log(
|
|
90
|
+
` ${c.dim('a diff compares the offline and remote stores side by side — both must be readable.')}`,
|
|
91
|
+
);
|
|
92
|
+
log('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The real diff: three grouped sections. Each section groups the divergent keys
|
|
96
|
+
// by scope; a scope with nothing in a section is omitted from it.
|
|
97
|
+
function renderDiff({ root, scopes, connection, remoteAvailable, result }) {
|
|
98
|
+
heading('LoreKit diff');
|
|
99
|
+
log(` project: ${c.dim(root)}`);
|
|
100
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
101
|
+
if (remoteAvailable) log(` remote: ${c.dim(connection.endpoint)}`);
|
|
102
|
+
|
|
103
|
+
const { groups, totals } = result;
|
|
104
|
+
|
|
105
|
+
// Surface any per-scope read errors up front — a scope that failed to read on
|
|
106
|
+
// one side can't be diffed, and its keys are neither "only" nor "conflicting".
|
|
107
|
+
const errored = groups.filter((g) => g.error);
|
|
108
|
+
if (errored.length) {
|
|
109
|
+
heading('Unreadable scopes');
|
|
110
|
+
for (const g of errored) log(` ${c.bold(g.scope)} ${c.yellow('!')} ${c.dim(g.error)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
renderSet('Local-only', 'present offline, absent remote', groups, (g) => g.localOnly);
|
|
114
|
+
renderSet('Remote-only', 'absent offline, present remote', groups, (g) => g.remoteOnly);
|
|
115
|
+
renderConflicts(groups);
|
|
116
|
+
|
|
117
|
+
if (totals.localOnly + totals.remoteOnly + totals.conflicting === 0 && !errored.length) {
|
|
118
|
+
log('');
|
|
119
|
+
log(` ${c.dim('the offline and remote stores are in sync for the applicable scopes')}`);
|
|
120
|
+
}
|
|
121
|
+
log('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// One "only in store X" section: group its entries by scope, key + preview per
|
|
125
|
+
// entry. Skipped entirely when no scope has any entry in it.
|
|
126
|
+
function renderSet(title, subtitle, groups, pick) {
|
|
127
|
+
const present = groups.filter((g) => pick(g).length);
|
|
128
|
+
if (!present.length) return;
|
|
129
|
+
heading(title);
|
|
130
|
+
log(` ${c.dim(subtitle)}`);
|
|
131
|
+
for (const g of present) {
|
|
132
|
+
log(` ${c.bold(g.scope)}`);
|
|
133
|
+
for (const e of pick(g)) {
|
|
134
|
+
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
135
|
+
log(` ${c.cyan('•')} ${e.key}${when}`);
|
|
136
|
+
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The conflicting section: same key in both stores, differing value/tags. Shows
|
|
142
|
+
// a short preview of each side so the divergence is visible at a glance.
|
|
143
|
+
function renderConflicts(groups) {
|
|
144
|
+
const present = groups.filter((g) => g.conflicting.length);
|
|
145
|
+
if (!present.length) return;
|
|
146
|
+
heading('Conflicting');
|
|
147
|
+
log(` ${c.dim('same key in both stores, but the value or tags differ')}`);
|
|
148
|
+
for (const g of present) {
|
|
149
|
+
log(` ${c.bold(g.scope)}`);
|
|
150
|
+
for (const conflict of g.conflicting) {
|
|
151
|
+
log(` ${c.yellow('•')} ${conflict.key}`);
|
|
152
|
+
log(` ${c.dim('offline')} ${preview(conflict.local.value)}`);
|
|
153
|
+
log(` ${c.dim('remote ')} ${preview(conflict.remote.value)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The `--json` payload. When not comparable:
|
|
159
|
+
// { root, scopes, comparable:false, offline:{available,reason}, remote:{...} }
|
|
160
|
+
// When comparable:
|
|
161
|
+
// { root, scopes, comparable:true, totals, groups:[{ scope, localOnly,
|
|
162
|
+
// remoteOnly, conflicting, error }] } — the exact shape `diffGroups` returns.
|
|
163
|
+
function buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }) {
|
|
164
|
+
if (!comparable) {
|
|
165
|
+
return {
|
|
166
|
+
root,
|
|
167
|
+
scopes,
|
|
168
|
+
comparable: false,
|
|
169
|
+
offline: offlineReason ? { available: false, reason: offlineReason } : { available: true },
|
|
170
|
+
remote: remoteReason ? { available: false, reason: remoteReason } : { available: true },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
root,
|
|
175
|
+
scopes,
|
|
176
|
+
comparable: true,
|
|
177
|
+
totals: result.totals,
|
|
178
|
+
groups: result.groups,
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
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/list.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import process from 'node:process';
|
|
12
12
|
import { resolveProjectRoot } from './config.mjs';
|
|
13
13
|
import { deriveScope } from './scope.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
16
|
import { scopeList, gather, renderSection } from './lessons-view.mjs';
|
|
17
17
|
import { log, heading, c } from './util.mjs';
|
|
@@ -49,10 +49,7 @@ export async function list(args) {
|
|
|
49
49
|
// A `LOREKIT_DENY=remote|local` privacy/compliance ceiling (deny-wins, never
|
|
50
50
|
// overridable) suppresses that section entirely — the same invariant the
|
|
51
51
|
// agent-facing control model enforces, honored here in the human read view.
|
|
52
|
-
const
|
|
53
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
54
|
-
const localDenied = deny('local');
|
|
55
|
-
const remoteDenied = deny('remote');
|
|
52
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
56
53
|
|
|
57
54
|
const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes);
|
|
58
55
|
const remoteAvailable = !remoteDenied && remote.usable();
|
package/src/search.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import process from 'node:process';
|
|
17
17
|
import { resolveProjectRoot } from './config.mjs';
|
|
18
18
|
import { deriveScope } from './scope.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveDenies } from './control.mjs';
|
|
20
20
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
21
|
import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
|
|
22
22
|
import { log, err, heading, c } from './util.mjs';
|
|
@@ -48,10 +48,7 @@ export async function search(args) {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
// Deny-wins section suppression, identical to `list`.
|
|
51
|
-
const
|
|
52
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
53
|
-
const localDenied = deny('local');
|
|
54
|
-
const remoteDenied = deny('remote');
|
|
51
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
55
52
|
|
|
56
53
|
const offline = localDenied
|
|
57
54
|
? { groups: [], total: 0 }
|
package/src/show.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// so the bin wraps it in `traceCommand`.
|
|
12
12
|
import process from 'node:process';
|
|
13
13
|
import { resolveProjectRoot } from './config.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
16
|
import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
|
|
17
17
|
import { log, err, heading, status, c } from './util.mjs';
|
|
@@ -56,10 +56,7 @@ export async function show(args) {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// Deny-wins section suppression, identical to `list`/`search`.
|
|
59
|
-
const
|
|
60
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
61
|
-
const localDenied = deny('local');
|
|
62
|
-
const remoteDenied = deny('remote');
|
|
59
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
63
60
|
|
|
64
61
|
const offline = localDenied
|
|
65
62
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
package/src/stats.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// `lorekit stats` — an at-a-glance overview of the lessons that apply to the
|
|
2
|
+
// current directory: how many live in each scope, split per store (offline vs
|
|
3
|
+
// remote), plus per-store and grand totals. Same Offline / Remote framing as
|
|
4
|
+
// `list`, but counts-only — no lesson bodies.
|
|
5
|
+
//
|
|
6
|
+
// Graceful by design (mirrors `list`/`search`/`show`): a `LOREKIT_DENY` ceiling
|
|
7
|
+
// suppresses a section, and an unconfigured remote degrades to a short note,
|
|
8
|
+
// never an error (exit 0). Read-only. Human-facing, so the bin wraps it in
|
|
9
|
+
// `traceCommand`.
|
|
10
|
+
//
|
|
11
|
+
// On cap usage: the hosted MCP surface exposes no tool that returns the user's
|
|
12
|
+
// total active-memory count or their configured cap (the cap is enforced by a
|
|
13
|
+
// DB trigger and read via `lorekit_get_limit`, neither reachable from a
|
|
14
|
+
// `memory.*` tool call), so `stats` deliberately reports only observed counts
|
|
15
|
+
// rather than guessing a `N / cap` ratio. Remote per-scope counts reflect what
|
|
16
|
+
// `memory.list` returns for each scope (server-side default page size), the
|
|
17
|
+
// same window `list` already shows.
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
20
|
+
import { deriveScope } from './scope.mjs';
|
|
21
|
+
import { resolveDenies } from './control.mjs';
|
|
22
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
23
|
+
import { scopeList, gather, tallyGroups } from './lessons-view.mjs';
|
|
24
|
+
import { log, heading, status, c } from './util.mjs';
|
|
25
|
+
|
|
26
|
+
export async function stats(args) {
|
|
27
|
+
const root = resolveProjectRoot(args.dir);
|
|
28
|
+
const env = { ...process.env };
|
|
29
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
30
|
+
|
|
31
|
+
const scopeInfo = deriveScope(root);
|
|
32
|
+
// Default to every applicable scope; `--scope <s>` narrows to one (an explicit
|
|
33
|
+
// scope outside the applicable set is honoured — the user asked for it).
|
|
34
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
35
|
+
|
|
36
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
37
|
+
env,
|
|
38
|
+
endpoint: args.endpoint,
|
|
39
|
+
token: args.token,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Deny-wins section suppression, identical to `list`/`search`/`show`.
|
|
43
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
44
|
+
|
|
45
|
+
const offlineTally = localDenied ? { perScope: [], total: 0 } : tallyGroups(await gather(local, scopes));
|
|
46
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
47
|
+
const remoteTally = remoteAvailable
|
|
48
|
+
? tallyGroups(await gather(remote, scopes))
|
|
49
|
+
: { perScope: [], total: 0 };
|
|
50
|
+
|
|
51
|
+
const offlineSection = localDenied
|
|
52
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
53
|
+
: { available: true, ...offlineTally };
|
|
54
|
+
const remoteSection = remoteAvailable
|
|
55
|
+
? { available: true, ...remoteTally }
|
|
56
|
+
: {
|
|
57
|
+
available: false,
|
|
58
|
+
reason: remoteDenied
|
|
59
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
60
|
+
: remoteUnavailableReason(connection),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (args.json) {
|
|
64
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
|
|
65
|
+
} else {
|
|
66
|
+
heading('LoreKit stats');
|
|
67
|
+
log(` project: ${c.dim(root)}`);
|
|
68
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
69
|
+
|
|
70
|
+
renderStatsSection({ title: 'Offline' }, offlineSection, scopes);
|
|
71
|
+
renderStatsSection(
|
|
72
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
73
|
+
remoteSection,
|
|
74
|
+
scopes,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
log('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
|
|
81
|
+
// string, path, key, or token.
|
|
82
|
+
return {
|
|
83
|
+
exitCode: 0,
|
|
84
|
+
'lorekit.cli.stats.scope_count': scopes.length,
|
|
85
|
+
'lorekit.cli.stats.offline_count': offlineSection.available ? offlineSection.total : 0,
|
|
86
|
+
'lorekit.cli.stats.remote_count': remoteSection.available ? remoteSection.total : 0,
|
|
87
|
+
'lorekit.cli.stats.remote_available': remoteAvailable,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Render one store's count summary: an unavailable note, or a small right-
|
|
92
|
+
// aligned table of `scope count` rows plus a `total` line. Scopes with a read
|
|
93
|
+
// error show the error in place of a count so a partial read is never mistaken
|
|
94
|
+
// for an empty scope.
|
|
95
|
+
function renderStatsSection(header, section, scopes) {
|
|
96
|
+
heading(header.title);
|
|
97
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
98
|
+
|
|
99
|
+
if (!section.available) {
|
|
100
|
+
status('warn', 'unavailable', section.reason);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Index the tally by scope so every applicable scope prints a row (a scope
|
|
105
|
+
// with zero lessons still shows `0`, which is the point of an overview).
|
|
106
|
+
const byScope = new Map(section.perScope.map((s) => [s.scope, s]));
|
|
107
|
+
const width = Math.max(0, ...scopes.map((s) => s.length));
|
|
108
|
+
for (const scope of scopes) {
|
|
109
|
+
const row = byScope.get(scope) || { count: 0, error: null };
|
|
110
|
+
const label = scope.padEnd(width);
|
|
111
|
+
if (row.error) {
|
|
112
|
+
log(` ${c.bold(label)} ${c.yellow('!')} ${c.dim(row.error)}`);
|
|
113
|
+
} else {
|
|
114
|
+
log(` ${c.bold(label)} ${row.count}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
log(` ${c.dim('total'.padEnd(width))} ${c.bold(String(section.total))}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The `--json` payload: `{ root, scopes, offline, remote }` — each store a
|
|
121
|
+
// `{ available, total, scopes: [{ scope, count, error }] }` record (or an
|
|
122
|
+
// unavailable note), so a script gets the same shape regardless of which store
|
|
123
|
+
// the counts came from.
|
|
124
|
+
function buildJson({ root, scopes, offlineSection, remoteSection }) {
|
|
125
|
+
return {
|
|
126
|
+
root,
|
|
127
|
+
scopes,
|
|
128
|
+
offline: sectionJson(offlineSection),
|
|
129
|
+
remote: sectionJson(remoteSection),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sectionJson(section) {
|
|
134
|
+
if (!section.available) {
|
|
135
|
+
return { available: false, reason: section.reason, total: 0, scopes: [] };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
available: true,
|
|
139
|
+
total: section.total,
|
|
140
|
+
scopes: section.perScope.map((s) => ({ scope: s.scope, count: s.count, error: s.error })),
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -4,8 +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 /
|
|
8
|
-
// Dash0 so the maintainers can see which
|
|
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.
|
|
9
10
|
//
|
|
10
11
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
11
12
|
// • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
|
|
@@ -274,7 +275,7 @@ function normalizeExitCode(result) {
|
|
|
274
275
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
275
276
|
* are swallowed — the command result is never affected.
|
|
276
277
|
*
|
|
277
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | migrate
|
|
278
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
|
|
278
279
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
279
280
|
* @param {string} version CLI version (from package.json)
|
|
280
281
|
* @param {() => Promise<number>} run the command handler
|
package/src/tree.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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` (branch → repo → global, most-specific first) and
|
|
9
|
+
// keeps the FIRST value seen per key, so a more-specific scope shadows a broader
|
|
10
|
+
// scope's same-key lesson. `tree` resolves over that same `readOrder` set via
|
|
11
|
+
// the pure `resolvePrecedence`, so it shows the same resolution order the agent
|
|
12
|
+
// is injected with (the hook additionally caps the injected set at MAX_LESSONS;
|
|
13
|
+
// `tree` is uncapped, so a large workspace may list more winners than the hook
|
|
14
|
+
// injects).
|
|
15
|
+
//
|
|
16
|
+
// NOTE on scope coverage: `readOrder` is the injected set, and it deliberately
|
|
17
|
+
// excludes `project::` — the hooks never inject project-scope lessons, so `tree`
|
|
18
|
+
// doesn't either (browse those with `lorekit list`). Both stores are resolved
|
|
19
|
+
// independently (precedence is per-store — the hook reads one resolved store),
|
|
20
|
+
// in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
|
|
21
|
+
// `traceCommand` by the bin.
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
24
|
+
import { deriveScope } from './scope.mjs';
|
|
25
|
+
import { resolveDenies } from './control.mjs';
|
|
26
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
27
|
+
import { gather, resolvePrecedence, preview, shortDate } from './lessons-view.mjs';
|
|
28
|
+
import { log, heading, status, c } from './util.mjs';
|
|
29
|
+
|
|
30
|
+
export async function tree(args) {
|
|
31
|
+
const root = resolveProjectRoot(args.dir);
|
|
32
|
+
const env = { ...process.env };
|
|
33
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
34
|
+
|
|
35
|
+
const scopeInfo = deriveScope(root);
|
|
36
|
+
// Default to the injected resolution set (`readOrder`, most-specific first);
|
|
37
|
+
// `--scope <s>` narrows to one (honored even outside the set — the user asked).
|
|
38
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
|
|
39
|
+
|
|
40
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
41
|
+
env,
|
|
42
|
+
endpoint: args.endpoint,
|
|
43
|
+
token: args.token,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Deny-wins section suppression, identical to the other read commands.
|
|
47
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
48
|
+
|
|
49
|
+
const offlineResolved = localDenied ? null : resolvePrecedence(await gather(local, scopes));
|
|
50
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
51
|
+
const remoteResolved = remoteAvailable ? resolvePrecedence(await gather(remote, scopes)) : null;
|
|
52
|
+
|
|
53
|
+
const offlineSection = localDenied
|
|
54
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
55
|
+
: { available: true, ...offlineResolved };
|
|
56
|
+
const remoteSection = remoteAvailable
|
|
57
|
+
? { available: true, ...remoteResolved }
|
|
58
|
+
: {
|
|
59
|
+
available: false,
|
|
60
|
+
reason: remoteDenied
|
|
61
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
62
|
+
: remoteUnavailableReason(connection),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (args.json) {
|
|
66
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
|
|
67
|
+
} else {
|
|
68
|
+
heading('LoreKit resolution tree');
|
|
69
|
+
log(` project: ${c.dim(root)}`);
|
|
70
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
71
|
+
log(` ${c.dim('precedence order (most-specific first); a more-specific scope wins a duplicate key')}`);
|
|
72
|
+
|
|
73
|
+
renderTreeSection({ title: 'Offline' }, offlineSection);
|
|
74
|
+
renderTreeSection(
|
|
75
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
76
|
+
remoteSection,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
log('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bounded, non-PII telemetry extras — counts + a boolean, never a scope
|
|
83
|
+
// string, key, path, or token.
|
|
84
|
+
return {
|
|
85
|
+
exitCode: 0,
|
|
86
|
+
'lorekit.cli.tree.scope_count': scopes.length,
|
|
87
|
+
'lorekit.cli.tree.offline_winning': offlineSection.available ? offlineSection.winningTotal : 0,
|
|
88
|
+
'lorekit.cli.tree.offline_shadowed': offlineSection.available ? offlineSection.shadowedTotal : 0,
|
|
89
|
+
'lorekit.cli.tree.remote_shadowed': remoteSection.available ? remoteSection.shadowedTotal : 0,
|
|
90
|
+
'lorekit.cli.tree.remote_available': remoteAvailable,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Render one store's resolution: each scope in precedence order with its entries
|
|
95
|
+
// tagged winning (✓) or shadowed (↳ shadowed by <scope>). A scope with no
|
|
96
|
+
// entries and no error is omitted; a read error is surfaced in place.
|
|
97
|
+
function renderTreeSection(header, section) {
|
|
98
|
+
heading(header.title);
|
|
99
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
100
|
+
|
|
101
|
+
if (!section.available) {
|
|
102
|
+
status('warn', 'unavailable', section.reason);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
|
|
107
|
+
if (!printable.length) {
|
|
108
|
+
log(` ${c.dim('no lessons found in the applicable scopes')}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const g of printable) {
|
|
113
|
+
log(` ${c.bold(g.scope)}`);
|
|
114
|
+
if (g.error) {
|
|
115
|
+
log(` ${c.yellow('!')} ${c.dim(g.error)}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const e of g.entries) {
|
|
119
|
+
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
120
|
+
const mark = e.winning ? c.green('✓') : c.yellow('↳');
|
|
121
|
+
const tag = e.winning ? '' : ` ${c.dim(`shadowed by ${e.shadowedBy}`)}`;
|
|
122
|
+
log(` ${mark} ${e.key}${tag}${when}`);
|
|
123
|
+
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log(
|
|
128
|
+
` ${c.dim(`${section.winningTotal} winning, ${section.shadowedTotal} shadowed`)}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The `--json` payload: per-section resolved groups (each entry carrying its
|
|
133
|
+
// `winning` / `shadowedBy` tags), the flat `winners` list, and the counts — so a
|
|
134
|
+
// script gets the resolution verdict directly, in the same shape per store.
|
|
135
|
+
function buildJson({ root, scopes, offlineSection, remoteSection }) {
|
|
136
|
+
return {
|
|
137
|
+
root,
|
|
138
|
+
scopes,
|
|
139
|
+
offline: sectionJson(offlineSection),
|
|
140
|
+
remote: sectionJson(remoteSection),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sectionJson(section) {
|
|
145
|
+
if (!section.available) {
|
|
146
|
+
return { available: false, reason: section.reason, winners: [], groups: [] };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
available: true,
|
|
150
|
+
winningTotal: section.winningTotal,
|
|
151
|
+
shadowedTotal: section.shadowedTotal,
|
|
152
|
+
winners: section.winners,
|
|
153
|
+
groups: section.groups,
|
|
154
|
+
};
|
|
155
|
+
}
|