@lorekit/cli 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -308,6 +308,47 @@ coincidental overlaps. Any pair scoring at or above `--threshold` links (transit
308
308
  into one cluster; only clusters of 2+ members are reported, each with a similarity
309
309
  range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
310
310
 
311
+ ### `lorekit link` (alias `url`)
312
+
313
+ Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
314
+ pipes straight into your clipboard or a PR/Slack message:
315
+
316
+ ```bash
317
+ lorekit link # link to the current repo/branch context
318
+ lorekit link | pbcopy # copy it straight to the clipboard
319
+ lorekit link global # the Explorer filtered to global scope
320
+ lorekit link repo::owner/repo prefer-guards # open one lesson's detail sheet
321
+ lorekit link global::prefer-guards --json # { url, surface, base, params }
322
+ lorekit url --q "flaky test" --owner personal # search + ownership filter
323
+ ```
324
+
325
+ With no arguments it links to the cwd's **most-specific scope** ("share what I'm
326
+ looking at"). A single argument that is a valid scope — including a `repo::…` or
327
+ `branch::…::…` scope — links to the Explorer filtered to that scope; a scope
328
+ **and** key (two positionals, or the `scope::key` shorthand) links straight to
329
+ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
330
+ sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
331
+ reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
332
+ filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
333
+ (search), `--owner <all|personal|orgId>`, `--range`/`--from`/`--to`, `--archived`,
334
+ `--view <scope|time>`.
335
+
336
+ Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
337
+ how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
338
+ default on failure). A raw `?scope=global` would silently mean "all scopes", so
339
+ the link **must** be JSON-encoded to open the intended view. `--base <url>` (or
340
+ `LOREKIT_APP_URL`) overrides the dashboard host for self-hosted setups; the
341
+ default is `https://lorekit.io`. Read-only and network-free — it derives scopes
342
+ from git and builds a URL, never touching a store.
343
+
344
+ The **read commands take a `--link` flag** that short-circuits to print the
345
+ equivalent deep link, reusing the same builder: `show <scope::key> --link` → the
346
+ lesson link, `search foo --link` → `/lore?q="foo"` (+ scope), and `list --link` /
347
+ `tree --link` → the Explorer filtered to the **most-specific applicable scope**
348
+ (or `--scope` when given) — the dashboard filters one scope at a time, so the
349
+ multi-scope `list`/`tree` view maps to its primary scope. (The same JSON-encoded
350
+ links now back the hooks' write-confirmation and retrospective nudges.)
351
+
311
352
  ### `lorekit hook`
312
353
 
313
354
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -541,8 +582,10 @@ active deny constraints.
541
582
  | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
542
583
  | `--force` | Overwrite existing skill files (`install`) |
543
584
  | `--deep` | Write/read/delete round-trip (`doctor`) |
544
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
545
- | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable). For `scopes` it is a **substring filter** over the inventory |
585
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
586
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory |
587
+ | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
588
+ | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
546
589
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
547
590
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
548
591
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
package/bin/lorekit.mjs CHANGED
@@ -16,6 +16,7 @@ import { diff } from '../src/diff.mjs';
16
16
  import { tree } from '../src/tree.mjs';
17
17
  import { lint } from '../src/lint.mjs';
18
18
  import { dedupe } from '../src/dedupe.mjs';
19
+ import { link } from '../src/link.mjs';
19
20
  import { hook } from '../src/hook.mjs';
20
21
  import { migrate } from '../src/migrate.mjs';
21
22
  import { bootstrap } from '../src/bootstrap.mjs';
@@ -81,6 +82,12 @@ ${c.bold('Commands')}
81
82
  dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
82
83
  (Jaccard >= threshold, not semantic), grouped into clusters per
83
84
  store. --json, --scope <s>, --threshold <0..1>.
85
+ link (url) Print a shareable dashboard deep-link URL for the current context,
86
+ a scope, or a specific lesson (opens its detail sheet). No args
87
+ links to the cwd's most-specific scope. Filter flags mirror the
88
+ Explorer (--q / --owner / --range / --archived / --view); --base or
89
+ LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
90
+ lorekit link | pbcopy.
84
91
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
85
92
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
86
93
  See docs/byod.md for setup instructions.
@@ -102,8 +109,10 @@ ${c.bold('Options')}
102
109
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
103
110
  --mode <mode> Memory mode: off | local | remote (doctor override)
104
111
  --store <path> Local project-tier store directory (default: .lorekit)
105
- --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe)
106
- --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe)
112
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
113
+ --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
114
+ --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
115
+ --base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
107
116
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
108
117
  --from <path> Source store to migrate from (migrate)
109
118
  --to <tier> Migration destination tier: home | project (migrate;
@@ -228,11 +237,13 @@ ${c.bold('Options')}
228
237
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
229
238
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
230
239
  --store <path> Local project-tier store directory (default: .lorekit)
240
+ --link Print the Explorer deep-link for the most-specific scope (or --scope) instead of running (with --base / --json)
231
241
 
232
242
  ${c.bold('Examples')}
233
243
  npx @lorekit/cli list
234
244
  npx @lorekit/cli list --json
235
245
  npx @lorekit/cli list --scope global
246
+ npx @lorekit/cli list --scope global --link
236
247
  `,
237
248
  search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')}
238
249
 
@@ -252,11 +263,13 @@ ${c.bold('Options')}
252
263
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
253
264
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
254
265
  --store <path> Local project-tier store directory (default: .lorekit)
266
+ --link Print this view's dashboard deep-link URL instead of running (with --base / --json)
255
267
 
256
268
  ${c.bold('Examples')}
257
269
  npx @lorekit/cli search sandbox
258
270
  npx @lorekit/cli grep "flaky test" --json
259
271
  npx @lorekit/cli search migration --scope global
272
+ npx @lorekit/cli search "flaky test" --scope global --link
260
273
  `,
261
274
  write: `${c.bold('lorekit write')} — create or update a memory from the CLI
262
275
 
@@ -277,6 +290,11 @@ ${c.bold('Options')}
277
290
  --trigger <slug> Trigger context slug (default: none)
278
291
  --ttl-days <n> Days until auto-expiry 1–365 (remote only)
279
292
  --org <slug> Write to this org's scope (remote only)
293
+ --origin-repo <o/n> Override the derived provenance repository
294
+ --origin-branch <b> Override the derived provenance branch
295
+ --origin-commit <s> Override the derived provenance commit SHA
296
+ --origin-pr <n> The pull request this lesson came out of
297
+ --no-origin Record no provenance at all
280
298
  --remote Force write to the remote store
281
299
  --local Force write to the local offline store
282
300
  --json Machine-readable output
@@ -307,11 +325,13 @@ ${c.bold('Options')}
307
325
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
308
326
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
309
327
  --store <path> Local project-tier store directory (default: .lorekit)
328
+ --link Print this memory's dashboard deep-link URL instead of reading (with --base / --json)
310
329
 
311
330
  ${c.bold('Examples')}
312
331
  npx @lorekit/cli show global prefer-guard-clauses
313
332
  npx @lorekit/cli show global::prefer-guard-clauses
314
333
  npx @lorekit/cli show project::widget build-flags --json
334
+ npx @lorekit/cli show global prefer-guard-clauses --link
315
335
  `,
316
336
  stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
317
337
 
@@ -409,10 +429,12 @@ ${c.bold('Options')}
409
429
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
410
430
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
411
431
  --store <path> Local project-tier store directory (default: .lorekit)
432
+ --link Print the Explorer deep-link for the most-specific scope (or --scope) instead of running (with --base / --json)
412
433
 
413
434
  ${c.bold('Examples')}
414
435
  npx @lorekit/cli tree
415
436
  npx @lorekit/cli resolve --json
437
+ npx @lorekit/cli tree --scope global --link
416
438
  `,
417
439
  lint: `${c.bold('lorekit lint')} — flag low-quality memories across the applicable scopes
418
440
 
@@ -462,6 +484,42 @@ ${c.bold('Options')}
462
484
  ${c.bold('Examples')}
463
485
  npx @lorekit/cli dedupe
464
486
  npx @lorekit/cli dedupe --threshold 0.6 --json
487
+ `,
488
+ link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
489
+
490
+ ${c.bold('Usage')}
491
+ npx @lorekit/cli link [scope] [key] [options]
492
+ npx @lorekit/cli link <scope::key> [options]
493
+
494
+ Prints a ${c.cyan('lorekit.io/lore')} deep link to stdout — nothing else — so it pipes
495
+ cleanly into your clipboard or a message. With no arguments it links to the
496
+ current directory's most-specific scope ("share what I'm looking at"). Given a
497
+ scope it links to the Explorer filtered to that scope; given a scope AND key (or
498
+ the ${c.cyan('scope::key')} shorthand) it links straight to that lesson's detail sheet.
499
+
500
+ Every param is JSON-encoded exactly as the dashboard reads it, so the link opens
501
+ the intended view — a raw ${c.dim('?scope=global')} would silently mean "all scopes".
502
+
503
+ ${c.bold('Options')}
504
+ -d, --dir <path> Target project root (default: current directory)
505
+ --scope <scope> Scope to link to (when no positional scope is given)
506
+ --q <text> Pre-fill the Explorer search box
507
+ --owner <o> Ownership filter: all | personal | <orgId>
508
+ --range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
509
+ --from <date> Range start (shorthand for --range)
510
+ --to <date> Range end (shorthand for --range)
511
+ --archived Include archived memories
512
+ --view <mode> Explorer view: scope | time
513
+ --base <url> Dashboard base URL (else LOREKIT_APP_URL, default https://lorekit.io)
514
+ --json Machine-readable { url, surface, base, params }
515
+
516
+ ${c.bold('Examples')}
517
+ npx @lorekit/cli link # link to the current repo/branch context
518
+ npx @lorekit/cli link | pbcopy # copy it straight to the clipboard
519
+ npx @lorekit/cli link global # the Explorer filtered to global scope
520
+ npx @lorekit/cli link repo::owner/repo prefer-guards # open one lesson's detail sheet
521
+ npx @lorekit/cli link global::prefer-guards --json # { url, surface, base, params }
522
+ npx @lorekit/cli url --q "flaky test" --owner personal # search + ownership filter
465
523
  `,
466
524
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
467
525
 
@@ -517,6 +575,8 @@ const KNOWN_FLAGS = [
517
575
  'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
518
576
  'event', 'json', 'scope', 'threshold', 'help', 'version',
519
577
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
578
+ 'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
579
+ 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
520
580
  ];
521
581
 
522
582
  // Commands that write to disk / talk to the network on a human's behalf. These
@@ -524,12 +584,12 @@ const KNOWN_FLAGS = [
524
584
  // never fail on a stray flag, and only ever receive flags we control).
525
585
  const HUMAN_COMMANDS = new Set([
526
586
  'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
527
- 'diff', 'tree', 'lint', 'dedupe', 'migrate', 'write',
587
+ 'diff', 'tree', 'lint', 'dedupe', 'link', 'migrate', 'write',
528
588
  ]);
529
589
 
530
590
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
531
591
  // and telemetry both resolve to the real command name.
532
- const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
592
+ const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree', url: 'link' };
533
593
 
534
594
  async function main() {
535
595
  // Load a `.env` from the current directory (if any) before anything reads the
@@ -541,7 +601,7 @@ async function main() {
541
601
  const argv = process.argv.slice(2);
542
602
  const args = parseArgs(argv, {
543
603
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
544
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local'],
604
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived'],
545
605
  known: KNOWN_FLAGS,
546
606
  });
547
607
 
@@ -616,6 +676,8 @@ async function main() {
616
676
  return traceCommand('lint', args, VERSION, () => lint(args));
617
677
  case 'dedupe':
618
678
  return traceCommand('dedupe', args, VERSION, () => dedupe(args));
679
+ case 'link':
680
+ return traceCommand('link', args, VERSION, () => link(args));
619
681
  case 'migrate':
620
682
  return traceCommand('migrate', args, VERSION, () => migrate(args));
621
683
  case 'bootstrap':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,11 @@ import { deriveScope } from '../scope.mjs';
8
8
  // use, so the hook can't drift from them, and the hot path never pulls in the
9
9
  // `lessons-view.mjs` render/`util` stack.
10
10
  import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
11
+ // The deep-link builder is the SAME pure module the `link` command and the
12
+ // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
13
+ // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
14
+ // from the command-line links.
15
+ import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
11
16
 
12
17
  const MAX_LESSONS = 15;
13
18
  // Cap on lessons injected on a failure — a small, focused "you've seen this
@@ -176,10 +181,11 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
176
181
 
177
182
  // The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
178
183
  // Exported so tests can assert the URL shape without re-deriving the encoding.
184
+ // Delegates to the shared `loreScopeUrl` so the scope param is JSON-encoded the
185
+ // way the dashboard reads it — the previous raw `?scope=${scope}` fell through
186
+ // `useUrlState`'s `JSON.parse` and silently filtered to ALL scopes.
179
187
  export function loreUrl(writeScope) {
180
- const base = 'https://lorekit.io/lore';
181
- if (!writeScope || writeScope === 'global') return base;
182
- return `${base}?scope=${encodeURIComponent(writeScope)}`;
188
+ return loreScopeUrl(writeScope);
183
189
  }
184
190
 
185
191
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
@@ -200,16 +206,20 @@ export function retrospectiveNudge(scope, control) {
200
206
  }
201
207
 
202
208
  // Terse confirmation emitted via PostToolUse when a memory.write succeeded.
203
- // `key` is the lesson key from the tool response (may be null when the response
204
- // shape doesn't surface it). Includes a deep link to the scope's Lore Explorer
205
- // page so the user can verify immediately.
206
- export function writeConfirmation(scope, key) {
207
- const writeScope = scope.repoScope || 'global';
209
+ // `key` is the lesson key from the tool input (may be null when it isn't
210
+ // surfaced). `writtenScope` is the ACTUAL scope the write targeted (from the tool
211
+ // input) the link must point there, not at `repoScope`: a `global` (or project)
212
+ // write deep-linked to `repoScope` would open a lesson ref that doesn't exist.
213
+ // Falls back to the cwd's repo scope, then `global`, when the write scope is
214
+ // unknown. When the key is known the link opens that exact lesson's detail sheet
215
+ // (`?scope=…&lesson=…`); otherwise it filters the Explorer to the write scope —
216
+ // both JSON-encoded via the shared builder so they actually open the intended view.
217
+ export function writeConfirmation(scope, key, writtenScope) {
218
+ const target =
219
+ typeof writtenScope === 'string' && writtenScope ? writtenScope : scope.repoScope || 'global';
208
220
  const keyPart = key ? ` · ${key}` : '';
209
- const url = key
210
- ? `${loreUrl(writeScope)}&q=${encodeURIComponent(key)}`
211
- : loreUrl(writeScope);
212
- return `LoreKit: memory saved to ${writeScope}${keyPart}\nView: ${url}`;
221
+ const url = key ? buildLessonUrl(target, key) : loreScopeUrl(target);
222
+ return `LoreKit: memory saved to ${target}${keyPart}\nView: ${url}`;
213
223
  }
214
224
 
215
225
  // The nudge emitted when a tool failure is detected.
@@ -0,0 +1,194 @@
1
+ // Dependency-free deep-link builder for LoreKit dashboard URLs.
2
+ //
3
+ // Zero imports on purpose (the `lessons-pure.mjs` precedent): both the hook hot
4
+ // path (`core/lessons.mjs`) and the `link` command / `--link` flag share this
5
+ // without pulling in `util`/render/store code.
6
+ //
7
+ // THE governing constraint — every `/lore` Explorer param is read by the web
8
+ // app's `useUrlState` (`packages/web/src/lib/hooks/useUrlState.ts`) via
9
+ // `JSON.parse(searchParams.get(key))`, falling back to the param's DEFAULT on a
10
+ // parse failure and OMITTING any param whose value equals its default. So a URL
11
+ // value must be `encodeURIComponent(JSON.stringify(value))` — a JSON string,
12
+ // double-quoted for a string scope — NOT a raw token. A raw `?scope=global`
13
+ // fails `JSON.parse` and silently means "all scopes". This is the exact inverse
14
+ // of the app's read, and mirrors the production builder in
15
+ // `packages/web/src/components/dashboard/ScopeHealthCard.tsx`:
16
+ // `/lore?scope=${encodeURIComponent(JSON.stringify(health.scope))}`.
17
+
18
+ // The default dashboard base for the hosted deployment. Overridable per call
19
+ // (self-hosted setups) via `resolveAppBase` (`--base` flag / `LOREKIT_APP_URL`).
20
+ export const DEFAULT_APP_BASE = 'https://lorekit.io';
21
+
22
+ // The `/lore` Explorer param defaults, mirroring the `useUrlState` calls in
23
+ // `LoreExplorer.tsx` (+ the `lesson` param in `MemorySidebarProvider.tsx`)
24
+ // EXACTLY. A param whose value JSON-equals its default is omitted from the URL
25
+ // — the app would treat a present-but-default param as noise, and clean URLs
26
+ // match what the app itself produces.
27
+ export const LORE_PARAM_DEFAULTS = {
28
+ scope: null, // string | null — null means "all scopes"
29
+ q: '', // string search query
30
+ range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
31
+ owner: 'all', // 'all' | 'personal' | { orgId }
32
+ view: 'scope', // 'scope' | 'time'
33
+ archived: false, // boolean
34
+ lesson: null, // { scope, key } | null — opens the detail sheet
35
+ };
36
+
37
+ // A stable, readable param order (also makes URLs deterministic for tests).
38
+ // `scope` precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
39
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'view', 'archived', 'lesson'];
40
+
41
+ // Strip trailing slashes from a base URL, falling back to the default when the
42
+ // input is empty/absent. Pure.
43
+ function normalizeBase(base) {
44
+ const b = String(base == null ? '' : base)
45
+ .trim()
46
+ .replace(/\/+$/, '');
47
+ return b || DEFAULT_APP_BASE;
48
+ }
49
+
50
+ // Resolve the dashboard base URL: an explicit `--base` flag wins, then the
51
+ // `LOREKIT_APP_URL` env var, then the baked-in default. Pure — `env` is passed
52
+ // in (never read from `process` here) so it stays trivially unit-testable.
53
+ export function resolveAppBase({ base, env = {} } = {}) {
54
+ const flag = typeof base === 'string' && base.trim() ? base.trim() : '';
55
+ const fromEnv =
56
+ env && typeof env.LOREKIT_APP_URL === 'string' && env.LOREKIT_APP_URL.trim()
57
+ ? env.LOREKIT_APP_URL.trim()
58
+ : '';
59
+ return normalizeBase(flag || fromEnv || DEFAULT_APP_BASE);
60
+ }
61
+
62
+ // Encode ONE param value the way `useUrlState` reads it back:
63
+ // `encodeURIComponent(JSON.stringify(value))`. The exact inverse of the app's
64
+ // `JSON.parse(searchParams.get(key))`. Pure.
65
+ export function encodeParam(value) {
66
+ return encodeURIComponent(JSON.stringify(value));
67
+ }
68
+
69
+ // Build the `/lore` query string from a params object, JSON-encoding each value
70
+ // and OMITTING any param that is `undefined` or JSON-equal to its default (so
71
+ // the URL carries only the filters that actually change the view). Pure and
72
+ // total — an unknown key in `params` is ignored (only PARAM_ORDER is emitted).
73
+ export function buildLoreQuery(params = {}) {
74
+ const parts = [];
75
+ for (const key of PARAM_ORDER) {
76
+ const value = params[key];
77
+ if (value === undefined) continue;
78
+ if (JSON.stringify(value) === JSON.stringify(LORE_PARAM_DEFAULTS[key])) continue;
79
+ parts.push(`${key}=${encodeParam(value)}`);
80
+ }
81
+ return parts.join('&');
82
+ }
83
+
84
+ // Build a full `/lore` deep link from a params object. `base` defaults to the
85
+ // hosted dashboard; pass a resolved base for self-hosted setups. Pure.
86
+ export function buildLoreUrl(params = {}, { base = DEFAULT_APP_BASE } = {}) {
87
+ const cleanBase = normalizeBase(base);
88
+ const query = buildLoreQuery(params);
89
+ return `${cleanBase}/lore${query ? `?${query}` : ''}`;
90
+ }
91
+
92
+ // A shareable link to the Explorer filtered to a scope. `null`/`''` → the bare
93
+ // `/lore` (all scopes, the default); any concrete scope — INCLUDING `global` —
94
+ // → `?scope="<scope>"`. (`global` is a real scope the app can filter to, not a
95
+ // synonym for "no filter" — only the actual default `null` is omitted.) Pure.
96
+ export function loreScopeUrl(scope, opts = {}) {
97
+ const params = scope ? { scope } : {};
98
+ return buildLoreUrl(params, opts);
99
+ }
100
+
101
+ // A shareable link that opens a specific lesson's detail sheet. Sets the
102
+ // `lesson` param (which opens the sheet) plus `scope` — NOT because scope is
103
+ // needed to RESOLVE the lesson (the sidebar's `useLoreData` reads one unfiltered
104
+ // recent set, `.limit(500)`, non-archived, so `scope` does not widen that
105
+ // lookup), but so the Explorer list BEHIND the sheet is filtered to the lesson's
106
+ // own scope, coherent with the detail view. A lesson older than that window or
107
+ // archived can still open blank — a dashboard-side limitation the link can't fix.
108
+ // `scope` is the lesson's own scope. Pure.
109
+ export function buildLessonUrl(scope, key, opts = {}) {
110
+ const params = { lesson: { scope, key } };
111
+ if (scope) params.scope = scope;
112
+ return buildLoreUrl(params, opts);
113
+ }
114
+
115
+ // Resolve a SINGLE positional `link` argument into a { scope, key } pair,
116
+ // disambiguating a bare scope from the `<scope>::<key>` shorthand. `isScope` is
117
+ // an injected validity predicate (the caller passes a `scopeIssue`-based check),
118
+ // keeping this module zero-import.
119
+ //
120
+ // The rule: split at the LAST `::` and take it as `<scope>::<key>` ONLY when the
121
+ // left side is itself a COMPLETE valid scope — otherwise the whole arg is the
122
+ // scope. Splitting on the last `::` (not the first) keeps a multi-segment scope
123
+ // whole (`repo::owner/name::key` → scope `repo::owner/name`, key `key`); gating
124
+ // on a valid left side means a bare `repo::owner/name` is NOT mis-split, because
125
+ // its left part `repo` is not a valid scope. This is the fix for the prior
126
+ // first-`::` split, which turned `link repo::acme/widget` into scope="repo" plus
127
+ // a bogus `acme/widget` key — breaking the shorthand for EVERY non-`global`
128
+ // scope. A malformed arg falls through to the scope, never a fabricated key. Pure.
129
+ export function resolveScopeArg(arg, isScope = () => false) {
130
+ const s = typeof arg === 'string' ? arg.trim() : '';
131
+ if (!s) return { scope: null, key: null };
132
+ const idx = s.lastIndexOf('::');
133
+ if (idx !== -1) {
134
+ const left = s.slice(0, idx).trim();
135
+ const right = s.slice(idx + 2).trim();
136
+ if (right && isScope(left)) return { scope: left, key: right };
137
+ }
138
+ return { scope: s, key: null };
139
+ }
140
+
141
+ // ── Flag → param coercion (pure, shared by the `link` command) ────────────────
142
+
143
+ // Coerce the `--owner` flag to an `OwnerFilter`: 'all' (default) / 'personal' /
144
+ // any other non-empty string → `{ orgId }`. Pure.
145
+ export function parseOwnerArg(owner) {
146
+ if (typeof owner !== 'string' || !owner || owner === 'all') return 'all';
147
+ if (owner === 'personal') return 'personal';
148
+ return { orgId: owner };
149
+ }
150
+
151
+ // Coerce the `--view` flag to a `ViewMode`: only 'time' is non-default; anything
152
+ // else (incl. absent/invalid) → 'scope'. Pure.
153
+ export function parseViewArg(view) {
154
+ return view === 'time' ? 'time' : 'scope';
155
+ }
156
+
157
+ // Coerce the date-range flags to a `{ from, to }` DateRange or null. `--range`
158
+ // (a JSON object string) wins; else `--from`/`--to` shorthand builds one (both
159
+ // keys always present so the shape matches the app's DateRange). A malformed
160
+ // `--range` yields null rather than throwing. Pure.
161
+ export function parseRangeArg({ range, from, to } = {}) {
162
+ if (typeof range === 'string' && range.trim()) {
163
+ try {
164
+ const v = JSON.parse(range);
165
+ if (v && typeof v === 'object') return v;
166
+ } catch {
167
+ /* malformed --range → no range */
168
+ }
169
+ return null;
170
+ }
171
+ const hasFrom = typeof from === 'string' && from;
172
+ const hasTo = typeof to === 'string' && to;
173
+ if (hasFrom || hasTo) {
174
+ return { from: hasFrom ? from : '', to: hasTo ? to : '' };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // The Explorer's most-specific applicable scope for the cwd — `readOrder`'s
180
+ // first non-global entry, or null when only `global` applies (→ a bare `/lore`).
181
+ // The single-scope representation a deep link can carry for a multi-scope view.
182
+ // Pure — takes an already-derived `deriveScope()` result.
183
+ export function mostSpecificScope({ readOrder = [] } = {}) {
184
+ return readOrder.find((s) => s && s !== 'global') ?? null;
185
+ }
186
+
187
+ // Classify a params object into a bounded surface label (non-PII — safe as a
188
+ // telemetry attribute): 'lesson' | 'search' | 'scope' | 'explorer'. Pure.
189
+ export function surfaceFor(params = {}) {
190
+ if (params.lesson) return 'lesson';
191
+ if (params.q) return 'search';
192
+ if (params.scope) return 'scope';
193
+ return 'explorer';
194
+ }
package/src/doctor.mjs CHANGED
@@ -282,14 +282,43 @@ async function deepCheckRemote(store, root, record) {
282
282
  record('fail', 'round-trip', `write failed: ${w.error ? w.error.message || w.error.code : w.networkError}`);
283
283
  return;
284
284
  }
285
- const r = await store.read({ scope: writeScope, key });
286
- const readBack = r.ok && JSON.stringify(r.entry || '').includes('round-trip');
287
- record(
288
- readBack ? 'pass' : 'warn',
289
- 'round-trip',
290
- readBack ? `wrote + read back in ${writeScope}` : 'wrote, but read-back was inconclusive',
291
- );
292
- await store.delete({ scope: writeScope, key, force: true });
285
+ // Everything after a SUCCESSFUL write runs under `finally`, because this
286
+ // probe writes to the user's REAL project (CI runs it against production on
287
+ // every deploy). A throw or an early return between the write and the delete
288
+ // leaves a synthetic row in that tenant with nothing to remove it — the read
289
+ // is a diagnostic, never a reason to abandon the row it created.
290
+ try {
291
+ const r = await store.read({ scope: writeScope, key });
292
+ const readBack = r.ok && JSON.stringify(r.entry || '').includes('round-trip');
293
+ record(
294
+ readBack ? 'pass' : 'warn',
295
+ 'round-trip',
296
+ readBack ? `wrote + read back in ${writeScope}` : 'wrote, but read-back was inconclusive',
297
+ );
298
+ } finally {
299
+ await removeProbeRow(store, writeScope, key, record);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Delete the probe row, reporting rather than swallowing a failure.
305
+ *
306
+ * A silent `.catch` here means a synthetic row left in the user's REAL project
307
+ * with no signal anywhere — the same class of invisible leak this cleanup work
308
+ * exists to end. It is a `warn`, not a `fail`: the round-trip the user asked
309
+ * about did happen, so this must not flip doctor's verdict; it must just be
310
+ * impossible to miss. Both the thrown case and the `{ ok: false }` case are
311
+ * covered, because a REST delete reports its failure in the return value.
312
+ */
313
+ async function removeProbeRow(store, writeScope, key, record) {
314
+ try {
315
+ const d = await store.delete({ scope: writeScope, key, force: true });
316
+ if (d && d.ok === false) {
317
+ record('warn', 'round-trip cleanup', `could not remove ${writeScope}::${key} — delete it manually`);
318
+ }
319
+ } catch (err) {
320
+ record('warn', 'round-trip cleanup', `could not remove ${writeScope}::${key}: ${err && err.message ? err.message : err}`);
321
+ }
293
322
  }
294
323
 
295
324
  async function deepCheckLocal(store, scope, record) {
@@ -302,14 +331,19 @@ async function deepCheckLocal(store, scope, record) {
302
331
  tags: ['skill::lorekit-memory', 'source::doctor'],
303
332
  trigger: 'manual',
304
333
  });
305
- const r = await store.read({ scope: writeScope, key });
306
- const readBack = w.ok && r.ok && r.entry && String(r.entry.value).includes('round-trip');
307
- record(
308
- readBack ? 'pass' : 'warn',
309
- 'round-trip',
310
- readBack ? `wrote + read back in ${writeScope}` : 'write/read-back was inconclusive',
311
- );
312
- await store.delete({ scope: writeScope, key, force: true });
334
+ // Same `finally` contract as the remote probe: the local store is a real
335
+ // store too, and a half-finished probe should not leave a row in it.
336
+ try {
337
+ const r = await store.read({ scope: writeScope, key });
338
+ const readBack = w.ok && r.ok && r.entry && String(r.entry.value).includes('round-trip');
339
+ record(
340
+ readBack ? 'pass' : 'warn',
341
+ 'round-trip',
342
+ readBack ? `wrote + read back in ${writeScope}` : 'write/read-back was inconclusive',
343
+ );
344
+ } finally {
345
+ await removeProbeRow(store, writeScope, key, record);
346
+ }
313
347
  }
314
348
 
315
349
  // Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
package/src/hook.mjs CHANGED
@@ -122,7 +122,13 @@ async function run(args) {
122
122
  const key = (parsed.toolInput && typeof parsed.toolInput.key === 'string')
123
123
  ? parsed.toolInput.key
124
124
  : null;
125
- emit(writeConfirmation(scope, key));
125
+ // The scope the write actually targeted (tool input) — the confirmation
126
+ // link must point there, not at the cwd's repo scope, or a global/project
127
+ // write would deep-link to a lesson ref that doesn't exist.
128
+ const writtenScope = (parsed.toolInput && typeof parsed.toolInput.scope === 'string' && parsed.toolInput.scope)
129
+ ? parsed.toolInput.scope
130
+ : null;
131
+ emit(writeConfirmation(scope, key, writtenScope));
126
132
  }
127
133
  } catch {
128
134
  // best-effort — never break the host