@lorekit/cli 1.55.3 → 1.57.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
@@ -443,6 +443,57 @@ coincidental overlaps. Any pair scoring at or above `--threshold` links (transit
443
443
  into one cluster; only clusters of 2+ members are reported, each with a similarity
444
444
  range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
445
445
 
446
+ ### `lorekit obligations`
447
+
448
+ Check a changed-file set against the **Surface-Partner Map** — a declarative
449
+ registry of known, path-keyed file partnerships (a mirrored module, a doc that
450
+ copies a claim, a generated artifact) mined from existing CI guards — and flag
451
+ any partner the map says the changed-set owes but doesn't contain:
452
+
453
+ ```bash
454
+ lorekit obligations supabase/functions/_shared/audit/audit.ts # positionals
455
+ lorekit obligations --files packages/schemas/src/shared/tool-catalog.ts --json
456
+ git diff --name-only origin/main... | lorekit obligations --strict # from a real diff
457
+ ```
458
+
459
+ This is a machine version of a recurring review finding: a fix to one surface
460
+ leaves its partner stale because the lessons documenting the partnership are
461
+ retrieved lexically (full-text search + recency) and rarely surface at edit
462
+ time for the exact file just touched. Each matched entry prints its obliged
463
+ partner files/actions, marks each as met (✓) or unmet (!), and cites the
464
+ memory `lessonKey` the partnership encodes.
465
+
466
+ An `obliges` element is a required partner path/glob, a `run:<action>`
467
+ advisory that is always reported but never gates `--strict` (some
468
+ partnerships are "regenerate this," not "edit this file"), or an "any of"
469
+ group satisfied by whichever of several candidates is present. `{name}` (or
470
+ `**/{name}`) in a `match`/`obliges` glob binds a mirrored module's relative
471
+ path (directories + stem, extension stripped), for the (rare) case where a
472
+ partner's path genuinely IS a predictable function of the source's, so one
473
+ entry covers every module instead of needing one per file.
474
+
475
+ The `edge-mirror`/`edge-mirror-core` entries (mcp-core ↔ the self-contained
476
+ Deno edge mirrors) do NOT use that glob mechanism: an edge mirror doesn't
477
+ reliably preserve mcp-core's directory structure (it may flatten or rename
478
+ it), so a symmetric-path reconstruction false-positives on exactly those
479
+ pairs. Instead, both entries are generated — one row per pair — from
480
+ `src/shared/mirror-pairs.mjs`, the single-source inventory
481
+ `packages/mcp-core/src/edge/edge-parity.spec.ts` also reads for its
482
+ byte-comparison drift guard, so the spec and this command can never disagree
483
+ about which files mirror which.
484
+
485
+ **Cwd-independent by design**: it matches the path STRINGS it is given
486
+ against the map and never reads the filesystem or resolves scope from the
487
+ current directory — the changed-set can come from a real `git diff`, a PR
488
+ file list, or by hand, from anywhere.
489
+
490
+ Exits 0 by default; `--strict` exits non-zero when any PATH obligation is
491
+ unmet. `--json` → `{ files, matched, unmet, ok }`. CLI-only (`native` — no MCP
492
+ tool, no REST route, no `tool-catalog.ts` entry): a path-matching lint utility
493
+ is not an operation surface. Slice 1 of a larger design — wiring a
494
+ `PreToolUse` hook to call this at edit time, and server-side retrieval
495
+ changes, are named follow-ups, not built here.
496
+
446
497
  ### `lorekit link` (alias `url`)
447
498
 
448
499
  Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
@@ -996,12 +1047,14 @@ also returns their headroom against the plan's memory cap.
996
1047
  | `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
997
1048
  | `--force` | Overwrite existing skill files (`install`) |
998
1049
  | `--deep` | Write/read/delete round-trip (`doctor`) |
999
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link` / `purge` / `purge-expired`) |
1050
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `link` / `purge` / `purge-expired`) |
1000
1051
  | `--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. On `show` / `write` it **names** the scope, overriding the positional |
1001
1052
  | `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
1002
1053
  | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
1003
1054
  | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
1004
1055
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
1056
+ | `--files <path>...` | Changed files to check (`obligations`); also accepted as positionals or newline-separated stdin |
1057
+ | `--strict` | Exit non-zero on any unmet obligation (`obligations`) |
1005
1058
  | `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
1006
1059
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
1007
1060
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
package/bin/lorekit.mjs CHANGED
@@ -74,6 +74,14 @@ ${c.bold('Commands')}
74
74
  dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
75
75
  (Jaccard >= threshold, not semantic), grouped into clusters per
76
76
  store. --json, --scope <s>, --threshold <0..1>.
77
+ obligations Check a changed-file set against the Surface-Partner Map: known,
78
+ path-keyed file partnerships (a mirrored module, a doc that
79
+ copies a claim, a generated artifact) mined from existing CI
80
+ guards. Prints each matched partnership's obliged partner
81
+ files/actions and flags any partner NOT in the given set.
82
+ Cwd-independent — matches path strings, never reads the FS.
83
+ --files <path>..., positionals, or stdin (newline-separated).
84
+ --json, --strict (exit non-zero on any unmet obligation).
77
85
  link (url) Print a shareable dashboard deep-link URL for the current context,
78
86
  a scope, or a specific lesson (opens its detail sheet). No args
79
87
  links to the cwd's most-specific scope. Filter flags mirror the
@@ -102,6 +110,10 @@ ${c.bold('Commands')}
102
110
  resolved store (local .lorekit/ offline, or remote passthrough) so
103
111
  .mcp.json can point at the CLI instead of mcp-remote. Speaks
104
112
  JSON-RPC on stdin/stdout — not run by hand.
113
+ completion Print a shell completion script for zsh or fish. Pipe it to your
114
+ shell's completion dir, or let \`install --completions\` wire it for
115
+ you. Completes commands, per-command flags, and — from the local
116
+ store — scopes and scope::key addresses.
105
117
 
106
118
  ${c.bold('Options')}
107
119
  -d, --dir <path> Target project root (default: current directory)
@@ -111,9 +123,11 @@ ${c.bold('Options')}
111
123
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
112
124
  --mode <mode> Memory mode: off | local | remote (doctor override)
113
125
  --store <path> Local project-tier store directory (default: .lorekit)
114
- --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
126
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / link)
115
127
  --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
116
128
  On show / write it NAMES the scope, overriding the positional
129
+ --files <path>... Changed files to check (obligations); also accepted as positionals or newline-separated stdin
130
+ --strict Exit non-zero on any unmet obligation (obligations)
117
131
  --key <key> Name the key explicitly (show / write / link) — the way to
118
132
  address a key that itself contains \`::\`
119
133
  --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
@@ -131,6 +145,7 @@ ${c.bold('Options')}
131
145
  --no-hooks Skip wiring the lifecycle hooks (install)
132
146
  --mcp-json Also write a committable project .mcp.json for Claude Code on
133
147
  the web — auth via \${LOREKIT_TOKEN}, no embedded secret (install)
148
+ --completions <s> Install shell completion: auto | zsh | fish | none (install)
134
149
  --force Overwrite existing skill files (install)
135
150
  --deep Do a write→read→delete round-trip (doctor)
136
151
  --telemetry Verify the OTLP export credential works (doctor)
@@ -207,6 +222,10 @@ ${c.bold('Options')}
207
222
  --no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
208
223
  --mcp-json Also write a committable project .mcp.json (\${LOREKIT_TOKEN} auth)
209
224
  for Claude Code on the web — always the repo-root file
225
+ --completions <s> Install shell completion: auto (detect \$SHELL) | zsh | fish | none.
226
+ Interactive runs prompt; non-interactive ones skip it unless
227
+ this flag is passed. zsh adds a guarded block to ~/.zshrc; fish
228
+ drops a file in ~/.config/fish/completions (auto-loaded).
210
229
  --force Overwrite existing skill files
211
230
  -y, --yes Non-interactive; never prompt (defaults to --project, and to the
212
231
  already-wired hooks — all on a fresh install)
@@ -218,6 +237,7 @@ ${c.bold('Examples')}
218
237
  npx @lorekit/cli install --global --mcp-json --yes # local CLI + committable web config
219
238
  npx @lorekit/cli install --hooks read-only --yes
220
239
  npx @lorekit/cli install --no-hooks --yes
240
+ npx @lorekit/cli install --completions auto --yes # detect \$SHELL and wire completion
221
241
  `,
222
242
  uninstall: `${c.bold('lorekit uninstall')} — reverse install for the chosen scope
223
243
 
@@ -566,6 +586,41 @@ ${c.bold('Examples')}
566
586
  npx @lorekit/cli dedupe
567
587
  npx @lorekit/cli dedupe --threshold 0.6 --json
568
588
  npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
589
+ `,
590
+ obligations: `${c.bold('lorekit obligations')} — check a changed-file set against the Surface-Partner Map
591
+
592
+ ${c.bold('Usage')}
593
+ npx @lorekit/cli obligations <path>... [options]
594
+ npx @lorekit/cli obligations --files <path>... [options]
595
+ git diff --name-only | npx @lorekit/cli obligations [options]
596
+
597
+ Checks a changed-file set against a declarative registry of known, path-keyed
598
+ file partnerships (a mirrored module, a doc that copies a claim, a generated
599
+ artifact) mined from existing CI guards — a machine version of the recurring
600
+ review finding "you fixed one surface and left its partner stale." For each
601
+ matched partnership it prints the obliged partner files/actions and flags any
602
+ partner NOT in the given changed-set, citing the memory lesson the
603
+ partnership encodes.
604
+
605
+ Cwd-INDEPENDENT: it matches the path STRINGS it is given against the map — it
606
+ never reads the filesystem or resolves scope from the current directory, so
607
+ the changed-set can come from anywhere (a git diff, a PR file list, by hand).
608
+
609
+ The changed-set is positionals unioned with ${c.cyan('--files')} (its single-value
610
+ form — extra paths after it fall through as positionals); when neither is
611
+ given, it falls back to stdin lines (newline-separated, trimmed, non-empty),
612
+ read only when stdin is piped.
613
+
614
+ ${c.bold('Options')}
615
+ --files <path>... Changed files to check (also: positionals, stdin)
616
+ --strict Exit non-zero when any path obligation is unmet
617
+ (an advisory run: action never gates this)
618
+ --json Machine-readable output ({ files, matched, unmet, ok })
619
+
620
+ ${c.bold('Examples')}
621
+ npx @lorekit/cli obligations supabase/functions/_shared/audit/audit.ts
622
+ npx @lorekit/cli obligations --files packages/schemas/src/shared/tool-catalog.ts --json
623
+ git diff --name-only origin/main... | npx @lorekit/cli obligations --strict
569
624
  `,
570
625
  link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
571
626
 
@@ -811,6 +866,30 @@ Machine-facing: exposes the memory.* tools backed by the resolved store (local
811
866
  .lorekit/ offline, or remote passthrough) over JSON-RPC on stdin/stdout, so
812
867
  .mcp.json can point at the CLI instead of mcp-remote. Not run by hand.
813
868
 
869
+ ${c.bold('Options')}
870
+ -d, --dir <path> Target project root (default: current directory)
871
+ `,
872
+ completion: `${c.bold('lorekit completion')} — print a shell completion script
873
+
874
+ ${c.bold('Usage')}
875
+ lorekit completion <zsh|fish>
876
+
877
+ Prints the completion script for the given shell to stdout. It completes command
878
+ names and aliases, each command's own flags, and — read live from the LOCAL
879
+ store — scope values (\`--scope\`) and \`scope::key\` addresses (\`show\`, \`write\`,
880
+ \`archive\`, \`delete\`, \`restore\`, \`link\`). Dynamic completion is offline: it never
881
+ prompts for a token or hits the network, so a remote-only scope will not appear.
882
+
883
+ The easiest way to install it is ${c.cyan('lorekit install --completions auto')}, which
884
+ detects your shell and wires it up. To do it by hand:
885
+
886
+ ${c.bold('zsh')}
887
+ lorekit completion zsh > ~/.zsh/completions/_lorekit
888
+ # ensure that dir is on \$fpath before \`compinit\` in ~/.zshrc
889
+
890
+ ${c.bold('fish')}
891
+ lorekit completion fish > ~/.config/fish/completions/lorekit.fish
892
+
814
893
  ${c.bold('Options')}
815
894
  -d, --dir <path> Target project root (default: current directory)
816
895
  `,
@@ -821,7 +900,7 @@ ${c.bold('Options')}
821
900
  // typo like `--gloabl` should fail loudly, not quietly fall back to --project.
822
901
  const KNOWN_FLAGS = [
823
902
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
824
- 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
903
+ 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'completions', 'complete', 'force', 'deep', 'adapter',
825
904
  'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
826
905
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
827
906
  // `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
@@ -835,6 +914,8 @@ const KNOWN_FLAGS = [
835
914
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
836
915
  // Scale-aware survey flags
837
916
  'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
917
+ // `obligations`
918
+ 'files', 'strict',
838
919
  ];
839
920
 
840
921
  async function main() {
@@ -847,7 +928,7 @@ async function main() {
847
928
  const argv = process.argv.slice(2);
848
929
  const args = parseArgs(argv, {
849
930
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
850
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
931
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'strict'],
851
932
  known: KNOWN_FLAGS,
852
933
  });
853
934
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.55.3",
3
+ "version": "1.57.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": {
@@ -0,0 +1,68 @@
1
+ // `lorekit completion <zsh|fish>` — print a shell completion script.
2
+ //
3
+ // The generated scripts (see ../shared/completions.mjs) call back into this
4
+ // command for DYNAMIC values: `lorekit completion --complete scope` lists the
5
+ // scopes in the local store, `--complete key` lists `scope::key` addresses. That
6
+ // callback fires on every TAB, which is why `completion` is a MACHINE command in
7
+ // the registry — its stdout is a contract a shell parses (a script or a
8
+ // newline-delimited candidate list), and it is metered, not traced, exactly like
9
+ // `hook` / `mcp`. A span per keypress would be a firehose.
10
+ //
11
+ // Dynamic completion reads the LOCAL store only. It is deliberately offline: a
12
+ // TAB must be instant and must never prompt for a token or hit the network, so a
13
+ // scope that lives only in the remote store will not complete — a worthwhile
14
+ // trade for a completion that never blocks the prompt.
15
+ import process from 'node:process';
16
+ import { resolveProjectRoot } from '../shared/config.mjs';
17
+ import { resolveStores } from '../shared/stores.mjs';
18
+ import { log, err, c } from '../shared/util.mjs';
19
+ import { COMPLETION_SHELLS, renderCompletion } from '../shared/completions.mjs';
20
+
21
+ export async function completion(args) {
22
+ // Dynamic candidate mode: emit newline-delimited candidates for the shell to
23
+ // consume, then exit. Any failure degrades to an empty list (exit 0) so a
24
+ // broken store never surfaces an error at the prompt.
25
+ if (args.complete !== undefined) {
26
+ return emitCandidates(args);
27
+ }
28
+
29
+ const shell = typeof args._?.[1] === 'string' ? args._[1] : null;
30
+ if (!shell) {
31
+ err(`${c.red('Missing shell.')} Usage: lorekit completion <${COMPLETION_SHELLS.join('|')}>`);
32
+ return 1;
33
+ }
34
+ if (!COMPLETION_SHELLS.includes(shell)) {
35
+ err(`${c.red(`Unsupported shell: ${shell}.`)} Supported: ${COMPLETION_SHELLS.join(', ')}`);
36
+ err(`Run ${c.cyan('lorekit install --completions ' + shell)} to install it, or pipe this to your shell's completion dir.`);
37
+ return 1;
38
+ }
39
+
40
+ log(renderCompletion(shell));
41
+ return 0;
42
+ }
43
+
44
+ // Emit the candidate list for `--complete <kind>`. `scope` → distinct scope
45
+ // strings; `key` → `scope::key` addresses. Best-effort and offline.
46
+ async function emitCandidates(args) {
47
+ const kind = typeof args.complete === 'string' ? args.complete : '';
48
+ try {
49
+ const root = resolveProjectRoot(args.dir);
50
+ const { local } = resolveStores(root, { env: process.env });
51
+ const inventory = await local.listScopes();
52
+
53
+ if (kind === 'scope') {
54
+ for (const { scope } of inventory) log(scope);
55
+ return 0;
56
+ }
57
+ if (kind === 'key') {
58
+ for (const { scope } of inventory) {
59
+ const res = await local.list({ scope });
60
+ for (const entry of res.entries ?? []) log(`${scope}::${entry.key}`);
61
+ }
62
+ return 0;
63
+ }
64
+ } catch {
65
+ // Swallow — an unreadable store yields no candidates, never a prompt error.
66
+ }
67
+ return 0;
68
+ }
@@ -36,6 +36,32 @@ import { codex } from '../adapters/codex.mjs';
36
36
 
37
37
  const ADAPTERS = { claude, cursor, codex };
38
38
 
39
+ /**
40
+ * Bounded health vocabulary for the hook heartbeat. The hook always exits 0, so
41
+ * these are the only witness that a fire did its work: `ok` (ran clean),
42
+ * `store_unavailable` (no usable store to read/query), `degraded` (a store
43
+ * lookup threw and was swallowed — the host still got its output), `crash` (an
44
+ * unexpected throw the outer guard caught). Kept small so `lorekit.hook.outcome`
45
+ * stays a low-cardinality metric label.
46
+ */
47
+ const HOOK_OUTCOME = Object.freeze({
48
+ OK: 'ok',
49
+ STORE_UNAVAILABLE: 'store_unavailable',
50
+ DEGRADED: 'degraded',
51
+ CRASH: 'crash',
52
+ });
53
+
54
+ /**
55
+ * Map the mutable meter run() fills in to the bounded, non-PII counter
56
+ * dimensions. `event` is the host hook event (SessionStart / Stop / …), itself a
57
+ * small set; both labels are safe for a metric.
58
+ */
59
+ function hookMeterAttrs(meter) {
60
+ const attrs = { 'lorekit.hook.outcome': meter.outcome };
61
+ if (meter.event) attrs['lorekit.hook.event'] = meter.event;
62
+ return attrs;
63
+ }
64
+
39
65
  function readStdin() {
40
66
  return new Promise((resolve) => {
41
67
  let data = '';
@@ -48,15 +74,22 @@ function readStdin() {
48
74
  }
49
75
 
50
76
  export async function hook(args) {
77
+ // The meter run() fills in as it goes. Returned alongside the exit code so the
78
+ // metric-only dispatch (`meterCommand`) can count this fire with its health —
79
+ // the hook always exits 0, so this is the only signal a broken or degrading
80
+ // hook produces.
81
+ const meter = { event: null, outcome: HOOK_OUTCOME.OK };
51
82
  // Guarded so any unexpected error still exits 0 (never break the host agent).
52
83
  try {
53
- return await run(args);
84
+ const exitCode = await run(args, meter);
85
+ return { exitCode, meter: hookMeterAttrs(meter) };
54
86
  } catch {
55
- return 0;
87
+ meter.outcome = HOOK_OUTCOME.CRASH;
88
+ return { exitCode: 0, meter: hookMeterAttrs(meter) };
56
89
  }
57
90
  }
58
91
 
59
- async function run(args) {
92
+ async function run(args, meter) {
60
93
  const adapter = ADAPTERS[args.adapter];
61
94
  if (!adapter) {
62
95
  // Unknown adapter: stay silent, don't disrupt the host.
@@ -81,6 +114,8 @@ async function run(args) {
81
114
 
82
115
  if (!event) return 0;
83
116
 
117
+ meter.event = event;
118
+
84
119
  const intent = adapter.intentFor(event);
85
120
  if (intent === 'noop') return 0;
86
121
 
@@ -110,6 +145,8 @@ async function run(args) {
110
145
  ? control.hooksInstructions.SessionStart : null;
111
146
  if (!store) {
112
147
  // No store: emit a minimal header + instruction when present, then return.
148
+ // A SessionStart that cannot read lore is the highest-value failure to see.
149
+ meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
113
150
  if (sessionInstruction) {
114
151
  emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
115
152
  }
@@ -163,7 +200,7 @@ async function run(args) {
163
200
 
164
201
  try {
165
202
  const store = createStore(control);
166
- if (!store) return 0;
203
+ if (!store) { meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE; return 0; }
167
204
  const lessons = await promptLessonsFromStore(store, scope, terms, {
168
205
  // Delta only. Includes the SessionStart set, because "already shown"
169
206
  // has to mean shown by anything — a hook that only remembered its own
@@ -183,7 +220,9 @@ async function run(args) {
183
220
  }));
184
221
  } catch {
185
222
  // Best-effort, like every other branch: the user's turn proceeds either
186
- // way, and a store hiccup must never cost them their prompt.
223
+ // way, and a store hiccup must never cost them their prompt — but record
224
+ // that the lookup degraded so a store that always throws is visible.
225
+ meter.outcome = HOOK_OUTCOME.DEGRADED;
187
226
  }
188
227
  return 0;
189
228
  }
@@ -210,6 +249,7 @@ async function run(args) {
210
249
  }
211
250
  } catch {
212
251
  // best-effort — never break the host
252
+ meter.outcome = HOOK_OUTCOME.DEGRADED;
213
253
  }
214
254
  return 0;
215
255
  }
@@ -224,6 +264,7 @@ async function run(args) {
224
264
  let relevant = null;
225
265
  try {
226
266
  const store = createStore(control);
267
+ if (!store) meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
227
268
  if (store) {
228
269
  // QUERY the store across the scope hierarchy for lessons matching this
229
270
  // failure — not a post-filter of the SessionStart-injected set, which
@@ -235,6 +276,7 @@ async function run(args) {
235
276
  }
236
277
  } catch {
237
278
  relevant = null; // never let a lesson lookup break the failure nudge
279
+ meter.outcome = HOOK_OUTCOME.DEGRADED;
238
280
  }
239
281
  const nudge = failureNudge(parsed.toolName, scope, control);
240
282
  emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
@@ -29,6 +29,7 @@ import {
29
29
  } from '../shared/config.mjs';
30
30
  import { buildRemoteUrl, splitEndpoint } from '../shared/mcp.mjs';
31
31
  import { deriveScope } from '../shared/scope.mjs';
32
+ import { COMPLETION_SHELLS, detectShell, installCompletion } from '../shared/completions.mjs';
32
33
  import { log, heading, status, select, err, c } from '../shared/util.mjs';
33
34
 
34
35
  // The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
@@ -179,6 +180,25 @@ function requestedHookMode(args) {
179
180
  return null;
180
181
  }
181
182
 
183
+ // Shell-completion install modes for `--completions`. `auto` detects the shell
184
+ // from $SHELL; a shell name installs that one; `none` skips. Kept a small closed
185
+ // set so a typo fails loudly rather than silently doing nothing.
186
+ export const COMPLETION_MODES = ['auto', 'none', ...COMPLETION_SHELLS];
187
+
188
+ // Same sentinel discipline as INVALID_HOOK_MODE: a bare or empty `--completions`
189
+ // is a usage error, not an absent flag, so it never silently falls through to
190
+ // the interactive/skip default.
191
+ export const INVALID_COMPLETION_MODE = '(missing value)';
192
+
193
+ // Resolve the requested completion mode from the flag, or null when it was not
194
+ // passed (the caller then prompts interactively / skips non-interactively).
195
+ export function requestedCompletionMode(args) {
196
+ const raw = args.completions;
197
+ if (typeof raw === 'string' && raw.trim()) return raw.trim().toLowerCase();
198
+ if (raw !== undefined && raw !== false) return INVALID_COMPLETION_MODE;
199
+ return null;
200
+ }
201
+
182
202
  export async function install(args) {
183
203
  const root = resolveProjectRoot(args.dir);
184
204
  const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
@@ -206,6 +226,21 @@ export async function install(args) {
206
226
  // write step even when the scope is already fully installed.
207
227
  const writeWebMcpJson = Boolean(args['mcp-json']);
208
228
 
229
+ // Validate `--completions` up front, same discipline as `--hooks`.
230
+ const requestedCompletion = requestedCompletionMode(args);
231
+ if (requestedCompletion === INVALID_COMPLETION_MODE) {
232
+ err(`\n --completions needs a mode. Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
233
+ return 1;
234
+ }
235
+ if (requestedCompletion !== null && !COMPLETION_MODES.includes(requestedCompletion)) {
236
+ err(`\n Unknown --completions mode "${requestedCompletion}". Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
237
+ return 1;
238
+ }
239
+ // An explicit request to install completion (auto / zsh / fish) must reach the
240
+ // completion step even on an otherwise complete install. `none` is skip-only,
241
+ // like `--no-hooks`, so it never justifies the bypass.
242
+ const completionsExplicit = requestedCompletion !== null && requestedCompletion !== 'none';
243
+
209
244
  heading('LoreKit install');
210
245
  log(` project: ${c.dim(root)}`);
211
246
 
@@ -231,7 +266,7 @@ export async function install(args) {
231
266
 
232
267
  const wiredEvents = installedHookEvents(root, scope);
233
268
 
234
- if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson) {
269
+ if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson && !completionsExplicit) {
235
270
  // Surface a clear, useful already-installed summary.
236
271
  log('');
237
272
  log(
@@ -474,6 +509,52 @@ export async function install(args) {
474
509
  hooks = upsertClaudeHooks(root, scope, resolveHookRunner(), hookEvents);
475
510
  }
476
511
 
512
+ // 5c. Shell completion — independent of the project/global scope above, since
513
+ // completion lives in the user's shell config, not a repo. Resolve the
514
+ // target shell, then write the script (fish auto-loads its dir; zsh gets a
515
+ // guarded ~/.zshrc block). Opt-in by default: an interactive run prompts
516
+ // for the detected shell, a non-interactive one skips unless
517
+ // `--completions` was passed — writing to a shell rc unprompted would be
518
+ // too invasive for a plain `install --yes`.
519
+ let completionResult = null;
520
+ let completionShell = null;
521
+ let completionSkipReason = null;
522
+ {
523
+ let mode = requestedCompletion; // 'auto' | 'zsh' | 'fish' | 'none' | null
524
+ if (mode === null) {
525
+ if (nonInteractive) {
526
+ mode = 'none';
527
+ } else {
528
+ const detected = detectShell(process.env);
529
+ if (!detected) {
530
+ completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
531
+ mode = 'none';
532
+ } else {
533
+ log('');
534
+ const answer = await select(`Install ${detected} shell completion?`, [
535
+ { label: 'Yes', value: 'yes', hint: `write the ${detected} completion and wire it up` },
536
+ { label: 'No', value: 'no', hint: 'skip — add it later with `lorekit completion`' },
537
+ ]);
538
+ mode = answer === 'yes' ? detected : 'none';
539
+ }
540
+ }
541
+ }
542
+ if (mode === 'auto') {
543
+ const detected = detectShell(process.env);
544
+ if (detected) completionShell = detected;
545
+ else completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
546
+ } else if (mode === 'zsh' || mode === 'fish') {
547
+ completionShell = mode;
548
+ }
549
+ if (completionShell) {
550
+ try {
551
+ completionResult = installCompletion(completionShell, { home: homeDir() });
552
+ } catch (e) {
553
+ completionSkipReason = e.message;
554
+ }
555
+ }
556
+ }
557
+
477
558
  // Show global paths relative to ~ (a repo-relative path would be a mess of
478
559
  // ../../); project paths stay repo-relative.
479
560
  const display = (p) =>
@@ -550,6 +631,21 @@ export async function install(args) {
550
631
  );
551
632
  }
552
633
 
634
+ // Shell completion always lives under ~, so show it relative to home
635
+ // regardless of the install scope.
636
+ const homeDisplay = (p) => p.replace(homeDir(), '~');
637
+ if (completionResult) {
638
+ const where = completionResult.autoloaded
639
+ ? `${homeDisplay(completionResult.file)} — auto-loaded by fish`
640
+ : `${homeDisplay(completionResult.file)}${completionResult.rcUpdated ? ' + ~/.zshrc block' : ''}`;
641
+ status('pass', `completion ${completionResult.shell}`, `${where}; open a new shell to use it`);
642
+ } else if (completionShell) {
643
+ // A shell was resolved but the write failed — surface why, don't stay silent.
644
+ status('warn', 'completion', completionSkipReason || 'not installed');
645
+ } else if (completionSkipReason) {
646
+ status('info', 'completion', `skipped — ${completionSkipReason}; add it with \`lorekit completion\``);
647
+ }
648
+
553
649
  const kind = tokenKind(token);
554
650
  if (scopeWriteOwnedByWeb) {
555
651
  // `--project --mcp-json`: the committable web .mcp.json is the ONLY config
@@ -615,5 +711,11 @@ export async function install(args) {
615
711
  // Bounded, non-PII: which of the three presets this run landed on. Counting
616
712
  // the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
617
713
  // user picks when actually asked, which is the whole point of the prompt.
618
- return { exitCode: 0, 'lorekit.cli.hooks_mode': hookMode };
714
+ // `completions` records the shell wired (or 'none') a bounded enum, never a
715
+ // path.
716
+ return {
717
+ exitCode: 0,
718
+ 'lorekit.cli.hooks_mode': hookMode,
719
+ 'lorekit.cli.completions': completionResult ? completionResult.shell : 'none',
720
+ };
619
721
  }