@lorekit/cli 1.10.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 +61 -2
- package/bin/lorekit.mjs +99 -5
- package/package.json +1 -1
- package/src/dedupe.mjs +184 -0
- package/src/lessons-view.mjs +0 -0
- package/src/lint.mjs +152 -0
- package/src/telemetry.mjs +4 -4
- package/src/tree.mjs +155 -0
package/README.md
CHANGED
|
@@ -210,6 +210,64 @@ remote is unconfigured (or a store is denied), a meaningful diff is impossible,
|
|
|
210
210
|
so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
|
|
211
211
|
rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
212
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
|
+
|
|
213
271
|
### `lorekit hook`
|
|
214
272
|
|
|
215
273
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -392,8 +450,9 @@ active deny constraints.
|
|
|
392
450
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
393
451
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
394
452
|
| `--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) |
|
|
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`) |
|
|
397
456
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
398
457
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
399
458
|
| `-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',
|
|
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
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/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/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
|
|
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,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
|
+
}
|