@lorekit/cli 1.27.0 → 1.29.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
@@ -75,12 +75,41 @@ lorekit install --global # set it up for every project
75
75
  ```
76
76
 
77
77
  In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
78
- Flags: `--project` / `--global` pick the scope non-interactively; `--no-hooks`
79
- installs the skills + MCP only (memory stays model-invoked); `--yes` runs
78
+ Flags: `--project` / `--global` pick the scope non-interactively; `--yes` runs
80
79
  non-interactively (endpoint required via flag/env; scope defaults to project);
81
80
  `--force` overwrites an existing skill copy. Re-running is idempotent — the hook
82
81
  entries are updated in place, never duplicated.
83
82
 
83
+ #### Choosing the hooks
84
+
85
+ The hooks are a separate, explicit choice — they add a `lorekit hook` subprocess
86
+ to three Claude Code lifecycle events and write into `settings.json`, so install
87
+ asks rather than assuming. **None of them writes memory:** they inject context,
88
+ and the write is still the model calling `memory.write`.
89
+
90
+ | Mode | Wires | What you get |
91
+ |------|-------|--------------|
92
+ | `all` | `SessionStart`, `PostToolUseFailure`, `Stop` | Lessons injected at session start, plus a nudge on a tool failure and a friction-gated one at end of turn |
93
+ | `read-only` | `SessionStart` | Lessons injected; nothing ever nudges |
94
+ | `none` | — | Skills + MCP only; memory stays model-invoked |
95
+
96
+ ```bash
97
+ lorekit install --hooks read-only # inject lessons, never nudge
98
+ lorekit install --hooks none # remove any wired hooks
99
+ lorekit install --no-hooks --yes # don't wire new ones; leave existing alone
100
+ ```
101
+
102
+ In a TTY the prompt preselects whatever is **already wired**, so re-running
103
+ install never resurrects hooks you declined; a genuinely fresh install
104
+ preselects `all`. `--yes` / a non-TTY takes that same preselected value without
105
+ asking — `all` on a fresh install, otherwise whatever is already wired (`none`
106
+ if you previously removed them), and a hand-wired set that matches no preset
107
+ keeps exactly that set — no event is added or removed, though a stale hook
108
+ command is still refreshed. Pass `--hooks <mode>` to choose explicitly.
109
+ `--hooks none` removes hooks that are already there; `--no-hooks` only skips
110
+ wiring new ones. `lorekit doctor` reports which events are wired, and in which
111
+ scope.
112
+
84
113
  > The hook command uses a global `lorekit` when one is on your `PATH` (fast),
85
114
  > otherwise `npx -y @lorekit/cli`. Installing the CLI globally
86
115
  > (`npm i -g @lorekit/cli`) is recommended so hooks fire without an npx
@@ -321,6 +350,7 @@ lorekit link global # the Explorer filtered to global scop
321
350
  lorekit link repo::owner/repo prefer-guards # open one lesson's detail sheet
322
351
  lorekit link global::prefer-guards --json # { url, surface, base, params }
323
352
  lorekit url --q "flaky test" --owner personal # search + ownership filter
353
+ lorekit link global --tags "perf,ci" # Explorer filtered to labels
324
354
  ```
325
355
 
326
356
  With no arguments it links to the cwd's **most-specific scope** ("share what I'm
@@ -331,8 +361,9 @@ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
331
361
  sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
332
362
  reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
333
363
  filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
334
- (search), `--owner <all|personal|orgId>`, `--range`/`--from`/`--to`, `--archived`,
335
- `--view <scope|time>`.
364
+ (search), `--owner <all|personal|orgId>`, `--tags <a,b,c>` (label filter, AND
365
+ across labels; comma-separated or a JSON array), `--range`/`--from`/`--to`,
366
+ `--archived`, `--view <scope|time>`.
336
367
 
337
368
  Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
338
369
  how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
@@ -507,13 +538,20 @@ Both files share this schema — all fields optional:
507
538
  // tags appended to every memory.write from this repo/user
508
539
  // both layers merged: repo tags first, then user tags
509
540
 
541
+ "ttl.default": 90,
542
+ // days until a write that named no TTL expires
543
+ // repo wins over user (a scalar policy cannot merge)
544
+ // omit for the historical behaviour: memories are permanent
545
+
510
546
  "scope.defaults": {
511
547
  "repo::owner/name": { "tags": ["team"] },
512
- "branch::owner/name::": { "tags": ["ephemeral"] }
548
+ "branch::owner/name::": { "tags": ["ephemeral"], "ttl_days": 14 },
549
+ "global": { "ttl_days": null }
513
550
  },
514
- // per-scope tag defaults applied to writes whose scope
551
+ // per-scope tag and TTL defaults applied to writes whose scope
515
552
  // starts with the key; matched by prefix (no wildcards needed)
516
553
  // repo config only — this is a team-level write policy
554
+ // ttl_days: null means "permanent", overriding ttl.default
517
555
 
518
556
  // ── Hook behaviour ─────────────────────────────────────────────────────────
519
557
  "hooks.disabled": ["Stop"],
@@ -577,6 +615,58 @@ cannot override**:
577
615
  `lorekit doctor` to see the resolved mode, **which source decided it**, and any
578
616
  active deny constraints.
579
617
 
618
+ ### Default TTL
619
+
620
+ A memory with no TTL never expires. That is still the out-of-the-box behaviour,
621
+ and it is the right default for a lesson someone deliberately curated — but it is
622
+ the wrong one for the steady drip of observations a hook nudges an agent into
623
+ writing at the end of every run. `ttl.default` and
624
+ `scope.defaults.<prefix>.ttl_days` let a repo say how long its lore stays fresh.
625
+
626
+ **Precedence, most specific first:**
627
+
628
+ | # | Source | Wins because |
629
+ | - | ------ | ------------ |
630
+ | 1 | `--ttl-days` / `--clear-ttl` | An explicit flag is the caller's assertion about this one memory. `--clear-ttl` is how you keep something forever in a repo that defaults to expiring. |
631
+ | 2 | The longest matching `scope.defaults` prefix with a `ttl_days` key | The most specific scope is the one the config author meant. `null` there means permanent. |
632
+ | 3 | `ttl.default` | The repo-wide (or user-wide) fallback. |
633
+ | 4 | No expiry | Nothing configured. |
634
+
635
+ Prefix matching is `::`-delimited, so `branch::` covers every branch scope while
636
+ `repo::owner` does **not** cover `repo::owner/name` — `owner/name` is a single
637
+ segment. Tags from `scope.defaults` union across every matching prefix; a TTL
638
+ cannot, so exactly one entry wins.
639
+
640
+ A configured TTL that is out of range (or not a number) is **ignored** — the
641
+ write succeeds with no expiry rather than failing. Config is ambient state that
642
+ must never break an unrelated write; `--ttl-days 999`, by contrast, is a usage
643
+ error, because you typed it. `lorekit write` names the source in its output:
644
+
645
+ ```
646
+ expires in 90 days (from config)
647
+ ```
648
+
649
+ **Two limits worth knowing.** First, this is a **client-side** default: the
650
+ hosted `memory.write` contract is unchanged, so omitting `ttl_*` there still
651
+ means permanent. An agent talking straight to the MCP endpoint never sees your
652
+ config file. Second, a **hook cannot apply it** — hooks only read lore and emit
653
+ text; the write happens afterwards, in the agent's context. So the nudges instead
654
+ *advise* the resolved number:
655
+
656
+ ```
657
+ LoreKit: hit any friction worth remembering … Set ttl_days: 90 (this scope's
658
+ configured default) unless the lesson is durable enough to keep forever.
659
+ ```
660
+
661
+ That is advice, not enforcement. An agent that ignores it writes a permanent
662
+ memory, exactly as before.
663
+
664
+ **Refresh on update is free.** `memory_write` refreshes `expires_at` only when a
665
+ `ttl_*` is supplied, so re-writing the same `scope`+`key` with the default
666
+ applied slides the window forward — a lesson that keeps recurring keeps living,
667
+ and one nobody has seen in 90 days decays. Expired rows are swept nightly, which
668
+ also returns their headroom against the plan's memory cap.
669
+
580
670
  ## Options
581
671
 
582
672
  | Flag | Meaning |
@@ -592,7 +682,8 @@ active deny constraints.
592
682
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
593
683
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
594
684
  | `-y, --yes` | Non-interactive / apply; never prompt |
595
- | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
685
+ | `--hooks <mode>` | Lifecycle hooks to wire: `all` / `read-only` / `none` (`install`; `none` removes any already wired) |
686
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only. Leaves already-wired hooks alone (`install`) |
596
687
  | `--force` | Overwrite existing skill files (`install`) |
597
688
  | `--deep` | Write/read/delete round-trip (`doctor`) |
598
689
  | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
package/bin/lorekit.mjs CHANGED
@@ -40,8 +40,10 @@ ${c.bold('Commands')}
40
40
  LoreKit MCP server, and install the deterministic hooks (memories
41
41
  on SessionStart, a nudge on tool failure + at Stop). Prompts to
42
42
  install for this project (.claude) or globally for every project
43
- (~/.claude); --project / --global choose non-interactively,
44
- --no-hooks skips the hooks (skills stay model-invoked only).
43
+ (~/.claude); --project / --global choose non-interactively.
44
+ Also prompts whether to wire the hooks (all / read-only /
45
+ none); --hooks <mode> chooses non-interactively and --no-hooks
46
+ skips them (skills stay model-invoked only).
45
47
  uninstall Reverse install: remove the lorekit-memory + lorekit-setup skills,
46
48
  the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
47
49
  other servers, hooks, and settings are left untouched. Prompts
@@ -85,8 +87,8 @@ ${c.bold('Commands')}
85
87
  link (url) Print a shareable dashboard deep-link URL for the current context,
86
88
  a scope, or a specific lesson (opens its detail sheet). No args
87
89
  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
+ Explorer (--q / --owner / --tags / --range / --archived / --view);
91
+ --base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
90
92
  lorekit link | pbcopy.
91
93
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
92
94
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
@@ -119,6 +121,7 @@ ${c.bold('Options')}
119
121
  default routes each entry by scope)
120
122
  --apply Apply the migration (alias of --yes) (migrate)
121
123
  -y, --yes Non-interactive / apply; never prompt
124
+ --hooks <mode> Lifecycle hooks to wire: all | read-only | none (install)
122
125
  --no-hooks Skip wiring the lifecycle hooks (install)
123
126
  --force Overwrite existing skill files (install)
124
127
  --deep Do a write→read→delete round-trip (doctor)
@@ -162,8 +165,19 @@ ${c.bold('Usage')}
162
165
  npx @lorekit/cli install [options]
163
166
 
164
167
  Scaffolds the lorekit-memory (runtime read/write) and lorekit-setup (loop
165
- authoring) skills, adds the LoreKit MCP server, and wires the deterministic
166
- hooks (memories on SessionStart, a nudge on tool failure + at Stop).
168
+ authoring) skills, adds the LoreKit MCP server, and asks whether to wire the
169
+ deterministic hooks. The hooks inject context they never write memory
170
+ themselves; the write is still the model calling memory.write.
171
+
172
+ ${c.bold('Hook modes')}
173
+ all SessionStart (inject lessons) + PostToolUseFailure and Stop
174
+ (nudge you to record one). Preselected on a fresh install.
175
+ read-only SessionStart only — lessons are injected, nothing ever nudges.
176
+ none No hooks; the skills stay model-invoked only.
177
+
178
+ An interactive run preselects whatever is already wired, so re-running install
179
+ never resurrects hooks you declined. Answering "No hooks" (or --hooks none)
180
+ REMOVES hooks that are already there; --no-hooks only skips wiring new ones.
167
181
 
168
182
  ${c.bold('Options')}
169
183
  -d, --dir <path> Target project root (default: current directory)
@@ -171,13 +185,16 @@ ${c.bold('Options')}
171
185
  --global Install for every project: ~/.claude/skills + ~/.claude.json
172
186
  -e, --endpoint <url> LoreKit MCP endpoint (else LOREKIT_MCP_URL)
173
187
  -t, --token <token> LoreKit token: lk_rw_* read+write, lk_ro_* read-only, lk_wo_* write-only
174
- --no-hooks Skip wiring the lifecycle hooks (skill stays model-invoked only)
188
+ --hooks <mode> Wire the lifecycle hooks: all | read-only | none
189
+ --no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
175
190
  --force Overwrite existing skill files
176
- -y, --yes Non-interactive; never prompt (defaults to --project)
191
+ -y, --yes Non-interactive; never prompt (defaults to --project, and to the
192
+ already-wired hooks — all on a fresh install)
177
193
 
178
194
  ${c.bold('Examples')}
179
195
  npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
180
196
  npx @lorekit/cli install --global
197
+ npx @lorekit/cli install --hooks read-only --yes
181
198
  npx @lorekit/cli install --no-hooks --yes
182
199
  `,
183
200
  uninstall: `${c.bold('lorekit uninstall')} — reverse install for the chosen scope
@@ -506,6 +523,7 @@ ${c.bold('Options')}
506
523
  --scope <scope> Scope to link to (when no positional scope is given)
507
524
  --q <text> Pre-fill the Explorer search box
508
525
  --owner <o> Ownership filter: all | personal | <orgId>
526
+ --tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
509
527
  --range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
510
528
  --from <date> Range start (shorthand for --range)
511
529
  --to <date> Range end (shorthand for --range)
@@ -521,6 +539,7 @@ ${c.bold('Examples')}
521
539
  npx @lorekit/cli link repo::owner/repo prefer-guards # open one lesson's detail sheet
522
540
  npx @lorekit/cli link global::prefer-guards --json # { url, surface, base, params }
523
541
  npx @lorekit/cli url --q "flaky test" --owner personal # search + ownership filter
542
+ npx @lorekit/cli link global --tags "perf,ci" # Explorer filtered to labels
524
543
  `,
525
544
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
526
545
 
@@ -573,7 +592,7 @@ ${c.bold('Options')}
573
592
  // typo like `--gloabl` should fail loudly, not quietly fall back to --project.
574
593
  const KNOWN_FLAGS = [
575
594
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
576
- 'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
595
+ 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
577
596
  'event', 'json', 'scope', 'threshold', 'help', 'version',
578
597
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
579
598
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.27.0",
3
+ "version": "1.29.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": {
package/src/config.mjs CHANGED
@@ -144,20 +144,94 @@ function readServerFromFile(file) {
144
144
  return { server, url: url || null };
145
145
  }
146
146
 
147
+ // The hook wiring presets `install` offers. They are presets over
148
+ // CLAUDE_HOOK_EVENTS, NOT a per-event checkbox list — the meaningful decision a
149
+ // user makes is "read my lessons / also nudge me / neither", and `hooks.disabled`
150
+ // already exists for surgical per-event suppression after the fact.
151
+ // all — every lifecycle event (the recommended default)
152
+ // read-only — SessionStart only: lessons are injected, nothing ever nudges
153
+ // none — no hooks; the skills stay model-invoked only
154
+ export const HOOK_MODES = ['all', 'read-only', 'none'];
155
+
156
+ // Pure: the events a mode wires. An unknown mode yields the full set, so a
157
+ // mis-typed value can never silently disable the hooks — `install` validates the
158
+ // flag up front and refuses instead.
159
+ export function hookEventsForMode(mode) {
160
+ if (mode === 'none') return [];
161
+ if (mode === 'read-only') return ['SessionStart'];
162
+ return [...CLAUDE_HOOK_EVENTS];
163
+ }
164
+
165
+ // Pure inverse of `hookEventsForMode`: which mode does this set of wired events
166
+ // correspond to? `custom` for anything that matches no preset (e.g. someone hand-
167
+ // wired only `Stop`) — the caller must not silently rewrite such a setup.
168
+ export function hookModeFromEvents(events) {
169
+ const set = new Set(events || []);
170
+ for (const mode of HOOK_MODES) {
171
+ const want = hookEventsForMode(mode);
172
+ if (want.length === set.size && want.every((e) => set.has(e))) return mode;
173
+ }
174
+ return 'custom';
175
+ }
176
+
177
+ // Extract the flat list of hook command strings for one event out of the nested
178
+ // group shape Claude Code uses: { [event]: [ { hooks: [ { type, command } ] } ] }.
179
+ export function hookCommandsForEvent(hooksObj, event) {
180
+ const groups = hooksObj && Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
181
+ const commands = [];
182
+ for (const group of groups) {
183
+ const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
184
+ for (const h of inner) {
185
+ if (h && typeof h.command === 'string') commands.push(h.command);
186
+ }
187
+ }
188
+ return commands;
189
+ }
190
+
191
+ // Which CLAUDE_HOOK_EVENTS currently have a lorekit hook wired in this scope's
192
+ // settings file. Best-effort: an absent or unparseable file reads as none — this
193
+ // is a detection helper (it decides a prompt default and a doctor line), never a
194
+ // write path, so it must not throw. The single reader shared by `install` and
195
+ // `doctor` so the two can never disagree about what is wired.
196
+ export function installedHookEvents(root, scope = 'project') {
197
+ let hooks = {};
198
+ try {
199
+ const cfg = JSON.parse(fs.readFileSync(settingsPath(root, scope), 'utf8'));
200
+ if (cfg && typeof cfg.hooks === 'object' && cfg.hooks) hooks = cfg.hooks;
201
+ } catch {
202
+ return [];
203
+ }
204
+ return CLAUDE_HOOK_EVENTS.filter((event) =>
205
+ hookCommandsForEvent(hooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd)),
206
+ );
207
+ }
208
+
147
209
  // Wire the lorekit hook engine into Claude Code settings for the scope,
148
210
  // preserving all other settings and any non-lorekit hooks. Idempotent: an
149
211
  // existing lorekit hook entry per event is updated in place, never duplicated.
150
212
  // `runner` is the command prefix (e.g. 'lorekit' or 'npx -y @lorekit/cli').
151
- export function upsertClaudeHooks(root, scope, runner) {
213
+ //
214
+ // `events` selects WHICH of CLAUDE_HOOK_EVENTS to wire (default: all of them).
215
+ // Any lorekit entry for a CLAUDE_HOOK_EVENT *not* in the list is REMOVED — that
216
+ // pruning is what makes a downgrade (all → read-only) an actual downgrade rather
217
+ // than an additive no-op that leaves the nudges firing. Only lorekit's own
218
+ // entries are touched; a co-located third-party hook on the same event survives.
219
+ export function upsertClaudeHooks(root, scope, runner, events = CLAUDE_HOOK_EVENTS) {
152
220
  const file = settingsPath(root, scope);
153
221
  const config = readJsonIfExists(file) || {};
154
222
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
155
223
 
224
+ const wanted = new Set(events);
156
225
  let added = 0;
157
226
  let updated = 0;
158
227
  let unchanged = 0;
228
+ let removed = 0;
159
229
 
160
230
  for (const event of CLAUDE_HOOK_EVENTS) {
231
+ if (!wanted.has(event)) {
232
+ removed += pruneLorekitHooks(config.hooks, event);
233
+ continue;
234
+ }
161
235
  const command = `${runner} hook --adapter claude --event ${event} --dir "\${CLAUDE_PROJECT_DIR}"`;
162
236
  if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
163
237
  const groups = config.hooks[event];
@@ -183,8 +257,31 @@ export function upsertClaudeHooks(root, scope, runner) {
183
257
  }
184
258
  }
185
259
 
260
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
261
+
186
262
  writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
187
- return { file, added, updated, unchanged };
263
+ return { file, added, updated, unchanged, removed };
264
+ }
265
+
266
+ // Drop every lorekit hook entry for one event from a hooks object, tidying up
267
+ // the groups and the event key it empties. Returns how many entries went. Shared
268
+ // by `upsertClaudeHooks`'s pruning and `removeClaudeHooks`'s full teardown.
269
+ function pruneLorekitHooks(hooksObj, event) {
270
+ const groups = hooksObj[event];
271
+ if (!Array.isArray(groups)) return 0;
272
+
273
+ let removed = 0;
274
+ for (const group of groups) {
275
+ if (!group || !Array.isArray(group.hooks)) continue;
276
+ const before = group.hooks.length;
277
+ group.hooks = group.hooks.filter(
278
+ (h) => !(h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command)),
279
+ );
280
+ removed += before - group.hooks.length;
281
+ }
282
+ hooksObj[event] = groups.filter((g) => g && Array.isArray(g.hooks) && g.hooks.length > 0);
283
+ if (hooksObj[event].length === 0) delete hooksObj[event];
284
+ return removed;
188
285
  }
189
286
 
190
287
  // Throwing read — used by `install` so a corrupt .mcp.json aborts the write
@@ -279,20 +376,9 @@ export function removeClaudeHooks(root, scope = 'project') {
279
376
  }
280
377
 
281
378
  let removed = 0;
379
+ // Snapshot the keys: pruneLorekitHooks deletes the ones it empties.
282
380
  for (const event of Object.keys(config.hooks)) {
283
- const groups = config.hooks[event];
284
- if (!Array.isArray(groups)) continue;
285
- for (const group of groups) {
286
- if (!group || !Array.isArray(group.hooks)) continue;
287
- const before = group.hooks.length;
288
- group.hooks = group.hooks.filter(
289
- (h) => !(h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command)),
290
- );
291
- removed += before - group.hooks.length;
292
- }
293
- // Drop groups we emptied, then the event key if it has no groups left.
294
- config.hooks[event] = groups.filter((g) => g && Array.isArray(g.hooks) && g.hooks.length > 0);
295
- if (config.hooks[event].length === 0) delete config.hooks[event];
381
+ removed += pruneLorekitHooks(config.hooks, event);
296
382
  }
297
383
  if (Object.keys(config.hooks).length === 0) delete config.hooks;
298
384
 
package/src/control.mjs CHANGED
@@ -2,8 +2,10 @@
2
2
  // target, who decided, and which deny constraints are active. Also resolves
3
3
  // write-behaviour properties from the config layers:
4
4
  //
5
- // scope.defaults — map of scope-prefix → { tags } applied to every matching write
5
+ // scope.defaults — map of scope-prefix → { tags, ttl_days } applied to every
6
+ // matching write
6
7
  // tags.default — array of tags appended to every write (both layers merged)
8
+ // ttl.default — days until a write with no explicit TTL expires
7
9
  // hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
8
10
  // hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
9
11
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
@@ -53,6 +55,31 @@ export function normalizeStopMode(v) {
53
55
  return null;
54
56
  }
55
57
 
58
+ // A config value that is meant to be a number, or null when it is absent or is
59
+ // something else entirely. Numeric strings are accepted because JSON configs get
60
+ // hand-edited; the RANGE check happens later, at the point of use.
61
+ function firstNumber(v) {
62
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
63
+ if (typeof v === 'string' && v.trim() !== '') {
64
+ const n = Number(v);
65
+ return Number.isFinite(n) ? n : null;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // Whether a config layer DECLARED a scalar policy value — the layer-selection
71
+ // predicate for keys that cannot merge. Same shape as the `hooks.adapter` guard
72
+ // below: a layer that put something usable-looking there owns the decision, even
73
+ // when the something turns out to be unparseable. Selecting the layer on the
74
+ // PARSED value instead is the bug: `firstNumber(repo) ?? firstNumber(user)` makes
75
+ // a garbage repo value (`"ttl.default": "90 days"`) indistinguishable from an
76
+ // absent one, so the user layer silently takes over and the retention a write
77
+ // gets depends on state outside the repository — two developers on the same
78
+ // commit would disagree, and neither could tell from the checkout.
79
+ function declaresScalar(v) {
80
+ return typeof v === 'number' || (typeof v === 'string' && v.trim() !== '');
81
+ }
82
+
56
83
  function asList(v) {
57
84
  if (Array.isArray(v)) return v;
58
85
  if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
@@ -131,9 +158,34 @@ export function resolveControl({
131
158
  ...asList(userConfig['tags.default']),
132
159
  ].filter((t) => typeof t === 'string' && t.length > 0);
133
160
 
161
+ // `ttl.default` — days until a write that named no TTL expires. A SCALAR
162
+ // policy, so it cannot merge the way `tags.default` does: repo wins over
163
+ // user, matching `hooks.adapter`. Read as "the project decided how long its
164
+ // lore stays fresh"; a user who disagrees can still pass --ttl-days or
165
+ // --clear-ttl per write, which always outranks config.
166
+ //
167
+ // Deliberately NOT validated here. `resolveControl` is the pure resolver
168
+ // every command calls, including read-only ones — a `"ttl.default": 900`
169
+ // typo must not make `lorekit list` throw. The value is bounds-checked at
170
+ // the point of use (resolveDefaultTtlDays), where an invalid one degrades
171
+ // to "no default".
172
+ //
173
+ // Which is exactly why the LAYER is chosen before the value is parsed (see
174
+ // declaresScalar): "repo wins" has to hold for a repo value that is wrong,
175
+ // or a typo'd project policy silently becomes a per-machine one instead of
176
+ // degrading to "no default". An absent key and an explicit `null` still
177
+ // fall through to the user layer — only a declared, usable-looking value
178
+ // claims the decision.
179
+ const ttlDefaultRaw = declaresScalar(repoConfig['ttl.default'])
180
+ ? repoConfig['ttl.default']
181
+ : userConfig['ttl.default'];
182
+ const ttlDefault = firstNumber(ttlDefaultRaw);
183
+
134
184
  // `scope.defaults` — repo layer only (team-scoped write policy).
135
- // Schema: { "<scope-prefix>": { "tags": [...] } }
185
+ // Schema: { "<scope-prefix>": { "tags": [...], "ttl_days": <n> | null } }
136
186
  // Matched against a write's resolved scope using startsWith — no glob dep.
187
+ // `ttl_days: null` is meaningful (permanent), so a per-scope entry can opt
188
+ // out of `ttl.default` — see resolveDefaultTtlDays.
137
189
  const scopeDefaults =
138
190
  repoConfig['scope.defaults'] && typeof repoConfig['scope.defaults'] === 'object'
139
191
  ? repoConfig['scope.defaults']
@@ -186,6 +238,7 @@ export function resolveControl({
186
238
  denies,
187
239
  connection,
188
240
  tagsDefault,
241
+ ttlDefault,
189
242
  scopeDefaults,
190
243
  hooksDisabled,
191
244
  hooksStop,
@@ -13,6 +13,12 @@ import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
13
13
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
14
14
  // from the command-line links.
15
15
  import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
16
+ // The SAME resolver `lorekit write` applies, so the TTL the nudge advises an
17
+ // agent to send is byte-for-byte the one the CLI would have applied itself. A
18
+ // hook cannot set a TTL — it only reads and emits text; the write happens later,
19
+ // over MCP, in the agent's context. Advising the number is the only lever the
20
+ // hook has, which is exactly why it must not be a second, hand-kept copy.
21
+ import { resolveDefaultTtlDays, matchesScopePrefix } from '../store/ttl.mjs';
16
22
  import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
17
23
 
18
24
  const MAX_LESSONS = 15;
@@ -167,10 +173,10 @@ function tagsHint(writeScope, control) {
167
173
  const tags = [...tagsDefault];
168
174
  if (scopeDefaults) {
169
175
  for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
170
- if (
171
- writeScope === prefix ||
172
- writeScope.startsWith(prefix.endsWith('::') ? prefix : prefix + '::')
173
- ) {
176
+ // Tags UNION across every matching prefix — they accumulate, so a broad
177
+ // and a narrow entry both contribute. The TTL hint below deliberately does
178
+ // NOT: a memory has one expiry, so the most specific prefix wins outright.
179
+ if (matchesScopePrefix(writeScope, prefix)) {
174
180
  for (const t of Array.isArray(cfg.tags) ? cfg.tags : []) {
175
181
  if (typeof t === 'string' && t.length > 0 && !tags.includes(t)) tags.push(t);
176
182
  }
@@ -181,6 +187,19 @@ function tagsHint(writeScope, control) {
181
187
  return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
182
188
  }
183
189
 
190
+ // Build the TTL hint appended to a nudge. Empty string when the scope has no
191
+ // configured default, so an unconfigured repo's nudge is unchanged.
192
+ //
193
+ // Phrased as an instruction to pass `ttl_days` rather than as a statement about
194
+ // what will happen, because nothing enforces it: the agent is about to call
195
+ // `memory.write` over MCP, where omitting `ttl_days` still means permanent. The
196
+ // hint is advice with a number attached — the same posture as the tags hint.
197
+ function ttlHint(writeScope, control) {
198
+ const days = resolveDefaultTtlDays(writeScope, control || {});
199
+ if (days == null) return '';
200
+ return ` Set ttl_days: ${days} (this scope's configured default) unless the lesson is durable enough to keep forever.`;
201
+ }
202
+
184
203
  // One-line phrases for the detected friction reason codes (see core/friction.mjs),
185
204
  // so the nudge names what happened instead of a generic prompt.
186
205
  const REASON_PHRASES = {
@@ -208,7 +227,7 @@ function describeReasons(reasons) {
208
227
  // which is where a link is actually actionable.
209
228
  export function retrospectiveNudge(scope, control, { reasons = [] } = {}) {
210
229
  const writeScope = scope.repoScope || 'global';
211
- const hint = tagsHint(writeScope, control);
230
+ const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}`;
212
231
  const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
213
232
  ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
214
233
  const detected = describeReasons(reasons);
@@ -240,7 +259,7 @@ export function writeConfirmation(scope, key, writtenScope) {
240
259
  // scopeDefaults when the repo/user config defines them.
241
260
  export function failureNudge(toolName, scope, control) {
242
261
  const writeScope = scope.repoScope || 'global';
243
- const hint = tagsHint(writeScope, control);
262
+ const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}`;
244
263
  const instruction = control && control.hooksInstructions && control.hooksInstructions.PostToolUseFailure
245
264
  ? `\n\nProject instruction: ${control.hooksInstructions.PostToolUseFailure}` : '';
246
265
  return (
@@ -29,14 +29,17 @@ export const LORE_PARAM_DEFAULTS = {
29
29
  q: '', // string search query
30
30
  range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
31
31
  owner: 'all', // 'all' | 'personal' | { orgId }
32
+ tags: [], // string[] — label filter (AND across labels); [] means "no filter"
32
33
  view: 'scope', // 'scope' | 'time'
33
34
  archived: false, // boolean
34
35
  lesson: null, // { scope, key } | null — opens the detail sheet
35
36
  };
36
37
 
37
38
  // 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'];
39
+ // Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
40
+ // param last), so `tags` sits between `owner` and `view`. `scope` precedes
41
+ // `lesson` so a lesson link reads `?scope=…&lesson=…`.
42
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'tags', 'view', 'archived', 'lesson'];
40
43
 
41
44
  // Strip trailing slashes from a base URL, falling back to the default when the
42
45
  // input is empty/absent. Pure.
@@ -154,6 +157,44 @@ export function parseViewArg(view) {
154
157
  return view === 'time' ? 'time' : 'scope';
155
158
  }
156
159
 
160
+ // Coerce the `--tags` flag to a normalized `string[]` label filter, mirroring the
161
+ // web app's `normalizeTags` (`packages/web/src/lib/tag-filter.ts`): trim each
162
+ // entry, drop empties, de-duplicate, preserve first-seen order. Accepts either a
163
+ // JSON array string (`'["perf","ci"]'`) or the friendlier comma-separated form
164
+ // (`'perf, ci'`); a malformed JSON array falls back to comma-splitting rather
165
+ // than throwing. Returns `[]` for absent/empty input (the default → omitted from
166
+ // the URL). Pure.
167
+ export function parseTagsArg(tags) {
168
+ if (Array.isArray(tags)) return normalizeTagList(tags);
169
+ if (typeof tags !== 'string' || !tags.trim()) return [];
170
+ const s = tags.trim();
171
+ if (s.startsWith('[')) {
172
+ try {
173
+ const parsed = JSON.parse(s);
174
+ if (Array.isArray(parsed)) return normalizeTagList(parsed);
175
+ } catch {
176
+ /* malformed JSON array → fall through to comma-splitting */
177
+ }
178
+ }
179
+ return normalizeTagList(s.split(','));
180
+ }
181
+
182
+ // Trim, drop non-string/empty entries, and de-duplicate preserving order. The
183
+ // CLI-side twin of the web's `normalizeTags`; kept inline to keep this module
184
+ // zero-import. Pure.
185
+ function normalizeTagList(list) {
186
+ const seen = new Set();
187
+ const out = [];
188
+ for (const item of list) {
189
+ if (typeof item !== 'string') continue;
190
+ const t = item.trim();
191
+ if (!t || seen.has(t)) continue;
192
+ seen.add(t);
193
+ out.push(t);
194
+ }
195
+ return out;
196
+ }
197
+
157
198
  // Coerce the date-range flags to a `{ from, to }` DateRange or null. `--range`
158
199
  // (a JSON object string) wins; else `--from`/`--to` shorthand builds one (both
159
200
  // keys always present so the shape matches the app's DateRange). A malformed
package/src/doctor.mjs CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  SKILLS,
9
9
  resolveProjectRoot,
10
10
  skillInstallDir,
11
- settingsPath,
12
11
  CLAUDE_HOOK_EVENTS,
13
- LOREKIT_HOOK_RE,
12
+ installedHookEvents,
13
+ hookModeFromEvents,
14
14
  readLorekitServer,
15
15
  readMcpConfig,
16
16
  tokenKind,
@@ -66,6 +66,28 @@ export async function doctor(args) {
66
66
  }
67
67
  }
68
68
 
69
+ // 2.4. Which hooks are actually wired, and where. Hooks are an install-time
70
+ // CHOICE (`--hooks all|read-only|none`, or the prompt), so "why does nothing
71
+ // get remembered?" is answered HERE — without a positive report the only way
72
+ // to tell a deliberate `none` from a broken install is to read settings.json.
73
+ {
74
+ const perScope = ['project', 'global']
75
+ .map((s) => ({ scope: s, events: installedHookEvents(root, s) }))
76
+ .filter((entry) => entry.events.length > 0);
77
+ if (perScope.length === 0) {
78
+ record(
79
+ 'info',
80
+ 'hooks',
81
+ 'none wired — the skills work, but memory is model-invoked only. ' +
82
+ 'Run `lorekit install --hooks all` to wire them.',
83
+ );
84
+ } else {
85
+ for (const { scope, events } of perScope) {
86
+ record('pass', `hooks ${scope}`, `${hookModeFromEvents(events)} — ${events.join(', ')}`);
87
+ }
88
+ }
89
+ }
90
+
69
91
  // 2.5. Duplicate-hook detection — warn when the same lorekit hook event is
70
92
  // wired in both the project settings and the global settings. This causes
71
93
  // Claude Code to fire the hook twice per event, producing doubled terminal
@@ -349,43 +371,14 @@ async function deepCheckLocal(store, scope, record) {
349
371
  // Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
350
372
  // BOTH the project settings file (.claude/settings.json) and the global one
351
373
  // (~/.claude/settings.json). An empty array means no duplicates — healthy.
374
+ //
375
+ // Both sides read through `installedHookEvents`, the SAME detection `install`
376
+ // uses to preselect its hook prompt, so the two surfaces can never disagree
377
+ // about what is wired.
352
378
  function detectDuplicateHooks(root) {
353
- const dupes = [];
354
- const projectFile = settingsPath(root, 'project');
355
- const globalFile = settingsPath(root, 'global');
356
-
357
- let projectHooks = {};
358
- let globalHooks = {};
359
- try {
360
- const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
361
- if (cfg && typeof cfg.hooks === 'object') projectHooks = cfg.hooks;
362
- } catch { /* absent or unparseable — treat as empty */ }
363
- try {
364
- const cfg = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
365
- if (cfg && typeof cfg.hooks === 'object') globalHooks = cfg.hooks;
366
- } catch { /* absent or unparseable — treat as empty */ }
367
-
368
- for (const event of CLAUDE_HOOK_EVENTS) {
369
- const hasInProject = hooksForEvent(projectHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
370
- const hasInGlobal = hooksForEvent(globalHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
371
- if (hasInProject && hasInGlobal) dupes.push(event);
372
- }
373
- return dupes;
374
- }
375
-
376
- // Extract the flat list of hook command strings for one event from a hooks
377
- // object. Handles the nested-group shape Claude Code uses:
378
- // { [event]: [ { hooks: [ { type, command } ] } ] }
379
- function hooksForEvent(hooksObj, event) {
380
- const groups = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
381
- const commands = [];
382
- for (const group of groups) {
383
- const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
384
- for (const h of inner) {
385
- if (h && typeof h.command === 'string') commands.push(h.command);
386
- }
387
- }
388
- return commands;
379
+ const inProject = new Set(installedHookEvents(root, 'project'));
380
+ const inGlobal = new Set(installedHookEvents(root, 'global'));
381
+ return CLAUDE_HOOK_EVENTS.filter((event) => inProject.has(event) && inGlobal.has(event));
389
382
  }
390
383
 
391
384
  async function checkBYODStorage(record) {
package/src/install.mjs CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  upsertMcpServer,
12
12
  upsertClaudeHooks,
13
13
  resolveHookRunner,
14
- CLAUDE_HOOK_EVENTS,
14
+ HOOK_MODES,
15
+ hookEventsForMode,
16
+ hookModeFromEvents,
17
+ installedHookEvents,
15
18
  resolveConnection,
16
19
  tokenKind,
17
20
  homeDir,
@@ -20,7 +23,7 @@ import {
20
23
  } from './config.mjs';
21
24
  import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
22
25
  import { deriveScope } from './scope.mjs';
23
- import { log, heading, status, select, c } from './util.mjs';
26
+ import { log, heading, status, select, err, c } from './util.mjs';
24
27
 
25
28
  // The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
26
29
  const LOREKIT_MCP_ENDPOINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
@@ -60,11 +63,97 @@ function detectInstalled(root, scope) {
60
63
  };
61
64
  }
62
65
 
66
+ // The interactive hook choice, as data so it can be asserted on without a pty.
67
+ //
68
+ // Deliberately THREE options, not a yes/no: `SessionStart` is a pure read that
69
+ // injects existing lessons, while the other two only nudge. A single yes/no
70
+ // bundles them, so a user who declines because they don't want to be nudged
71
+ // also loses lesson injection — the thing LoreKit is for. Each hint says what
72
+ // the hooks DO (inject context, nudge); none of them writes memory, and copy
73
+ // that implied otherwise would ask for consent to something that never happens.
74
+ export const HOOK_PROMPT_OPTIONS = [
75
+ {
76
+ label: 'Yes, all of them',
77
+ value: 'all',
78
+ hint: 'inject lessons at session start; nudge on a tool failure and at end of turn',
79
+ },
80
+ {
81
+ label: 'Read-only',
82
+ value: 'read-only',
83
+ hint: 'inject lessons at session start; never nudge',
84
+ },
85
+ {
86
+ label: 'No hooks',
87
+ value: 'none',
88
+ hint: 'skills + MCP only; memory stays model-invoked',
89
+ },
90
+ ];
91
+
92
+ // Pure: which hook mode should the interactive prompt preselect?
93
+ //
94
+ // The default is the DETECTED state, not a constant — `install` is explicitly
95
+ // re-runnable (token refresh, `--force`, completing a partial install), so a
96
+ // constant "all" would silently resurrect hooks a user previously declined.
97
+ // A genuinely fresh install has nothing to detect, so it preselects `all`: that
98
+ // is the "opt in by default" the prompt is there to promote. A hand-wired subset
99
+ // that matches no preset (`custom`) preselects `all` too — there is no preset to
100
+ // re-offer, and the user still sees and chooses from the three options.
101
+ //
102
+ // That last clause is load-bearing and INTERACTIVE-ONLY: it is safe precisely
103
+ // because the user is then shown the list and picks. A `--yes` / non-TTY run has
104
+ // no such moment, so it must NOT take this value for a `custom` set — see
105
+ // `install`, which leaves a hand-wired wiring untouched instead.
106
+ export function defaultHookMode({ freshInstall, wiredEvents }) {
107
+ if (freshInstall) return 'all';
108
+ const detected = hookModeFromEvents(wiredEvents);
109
+ return detected === 'custom' ? 'all' : detected;
110
+ }
111
+
112
+ // Resolve the requested hook mode from the flags, or null when nothing was
113
+ // specified (the caller then prompts / falls back). `--hooks <mode>` is the
114
+ // explicit selector; `--no-hooks` is the pre-existing boolean and keeps its
115
+ // documented SKIP semantics — it never removes hooks that are already wired,
116
+ // which is why it maps to `none` here but is tracked separately below.
117
+ //
118
+ // A VALUELESS `--hooks` is a usage error, not an absent flag. `hooks` is not in
119
+ // `parseArgs`' `booleans` list, so `--hooks --yes` and a trailing `--hooks`
120
+ // both yield `true` and `--hooks=` yields `''`. Returning null for those would
121
+ // resolve to the DETECTED mode — the exact silent fallback the validation below
122
+ // exists to prevent — so they are surfaced as the sentinel `INVALID_HOOK_MODE`
123
+ // and rejected alongside `--hooks bogus`. Mirrors `write.mjs`'s bare
124
+ // `--ttl-days`, which feeds NaN to its validator for the same reason: an
125
+ // explicitly supplied flag is a caller assertion, so a malformed one must fail.
126
+ export const INVALID_HOOK_MODE = '(missing value)';
127
+
128
+ function requestedHookMode(args) {
129
+ const raw = args.hooks;
130
+ if (typeof raw === 'string' && raw.trim()) return raw.trim().toLowerCase();
131
+ if (raw !== undefined && raw !== false) return INVALID_HOOK_MODE;
132
+ if (args['no-hooks']) return 'none';
133
+ return null;
134
+ }
135
+
63
136
  export async function install(args) {
64
137
  const root = resolveProjectRoot(args.dir);
65
138
  const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
66
139
  const force = Boolean(args.force);
67
140
 
141
+ // Validate `--hooks` before touching anything on disk: a mistyped mode must
142
+ // fail loudly, never silently fall back to a different wiring than asked for.
143
+ const requestedMode = requestedHookMode(args);
144
+ if (requestedMode === INVALID_HOOK_MODE) {
145
+ err(`\n --hooks needs a mode. Valid modes: ${HOOK_MODES.join(' | ')}.`);
146
+ return 1;
147
+ }
148
+ if (requestedMode !== null && !HOOK_MODES.includes(requestedMode)) {
149
+ err(`\n Unknown --hooks mode "${requestedMode}". Valid modes: ${HOOK_MODES.join(' | ')}.`);
150
+ return 1;
151
+ }
152
+ // An explicit `--hooks` IS the intent to change the wiring, so it must reach
153
+ // the hook step even on an otherwise complete install (which normally short-
154
+ // circuits). `--no-hooks` is skip-only and never justifies that bypass.
155
+ const hooksFlagExplicit = typeof args.hooks === 'string' && args.hooks.trim() !== '';
156
+
68
157
  heading('LoreKit install');
69
158
  log(` project: ${c.dim(root)}`);
70
159
 
@@ -88,7 +177,9 @@ export async function install(args) {
88
177
  const globalState = detectInstalled(root, 'global');
89
178
  const currentState = scope === 'global' ? globalState : projectState;
90
179
 
91
- if (currentState.isFullyInstalled && !force) {
180
+ const wiredEvents = installedHookEvents(root, scope);
181
+
182
+ if (currentState.isFullyInstalled && !force && !hooksFlagExplicit) {
92
183
  // Surface a clear, useful already-installed summary.
93
184
  log('');
94
185
  log(
@@ -124,10 +215,20 @@ export async function install(args) {
124
215
  log(` ${c.yellow('Token: none configured — reads/writes will fail until a token is set')}`);
125
216
  }
126
217
 
218
+ // Hooks are a user choice now, so an already-installed run must SAY which
219
+ // one is in effect — otherwise "why does nothing get remembered?" has no
220
+ // answer here and the user has to go read settings.json.
221
+ log(
222
+ wiredEvents.length > 0
223
+ ? ` ${c.dim(`Hooks: ${wiredEvents.join(', ')}`)}`
224
+ : ` ${c.dim('Hooks: none wired — the skills work, but only when the model invokes them')}`,
225
+ );
226
+
127
227
  log('');
128
228
  log(` Run ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
229
+ log(` Change the hooks with ${c.cyan(`--hooks ${HOOK_MODES.join('|')}`)}.`);
129
230
  log(` Pass ${c.cyan('--force')} to reinstall and overwrite existing files.`);
130
- return 0;
231
+ return { exitCode: 0, 'lorekit.cli.hooks_mode': hookModeFromEvents(wiredEvents) };
131
232
  }
132
233
 
133
234
  // Partial install — note what's already there vs what will be added.
@@ -179,14 +280,67 @@ export async function install(args) {
179
280
  const remoteUrl = buildRemoteUrl(endpoint, token);
180
281
  const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
181
282
 
182
- // 5b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
183
- // Claude plugin adds on top of the skill: lessons injected on every
184
- // SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
185
- // firing the shared `lorekit hook` engine, which reads the same config.
186
- const wireHooks = !args['no-hooks'];
283
+ // 5b. Hooks the deterministic layer the Claude plugin adds on top of the
284
+ // skill, firing the shared `lorekit hook` engine (which reads the same
285
+ // config). NONE of them write memory: SessionStart injects existing
286
+ // lessons, PostToolUseFailure surfaces relevant ones plus a write nudge,
287
+ // Stop fires the friction-gated retrospective nudge. All three only emit
288
+ // context — the write is still the model calling `memory.write`. The
289
+ // prompt copy below says exactly that, because a user who declines
290
+ // "automatic memory writing" would be declining something that never
291
+ // happens and losing lesson injection, which is the product.
292
+ let hookMode = requestedMode;
293
+ // A hand-wired set matching no preset (`custom`) is the one state the three
294
+ // options cannot express. Interactively that is fine — `defaultHookMode`
295
+ // preselects `all` and the user still chooses. A `--yes` / non-TTY run never
296
+ // gets that moment, so taking the preselection there would WIRE `all` and
297
+ // silently re-add the events the user hand-removed — exactly what
298
+ // `hookModeFromEvents` tells callers not to do, and the opposite of the
299
+ // documented "otherwise whatever is already wired". So keep exactly that set
300
+ // — no event added or removed, though the command string is still refreshed
301
+ // below; `--hooks <mode>` remains the way to change it on purpose.
302
+ let preserveCustomHooks = false;
303
+ if (hookMode === null) {
304
+ const preselect = defaultHookMode({
305
+ freshInstall: !currentState.hasSkills && !currentState.hasMcp && wiredEvents.length === 0,
306
+ wiredEvents,
307
+ });
308
+ if (nonInteractive) {
309
+ preserveCustomHooks = hookModeFromEvents(wiredEvents) === 'custom';
310
+ hookMode = preserveCustomHooks ? 'custom' : preselect;
311
+ } else {
312
+ log('');
313
+ hookMode = await select('Install the LoreKit lifecycle hooks?', HOOK_PROMPT_OPTIONS, {
314
+ defaultIndex: Math.max(0, HOOK_PROMPT_OPTIONS.findIndex((o) => o.value === preselect)),
315
+ });
316
+ }
317
+ }
318
+
319
+ // `hookEventsForMode` maps any unknown mode to the full set, so `custom` must
320
+ // never reach it — the preserved wiring IS the event list here.
321
+ const hookEvents = preserveCustomHooks ? [...wiredEvents] : hookEventsForMode(hookMode);
322
+ // `--no-hooks` is skip-only by contract: it has always meant "don't wire
323
+ // them", never "take away the ones already there". An interactive `No hooks`
324
+ // (or an explicit `--hooks none`) is an unambiguous request to remove.
325
+ const skipHooksOnly = hookMode === 'none' && Boolean(args['no-hooks']) && !hooksFlagExplicit;
326
+ // Nothing to wire and nothing to remove ⇒ don't create a settings.json at all.
327
+ // `wiredEvents` reads as empty for an unparseable settings.json, so a `none`
328
+ // run against one is a silent no-op — correct, not a gap: Claude Code cannot
329
+ // parse that file either, so no lorekit hook is firing from it. Any mode that
330
+ // WIRES still goes through `upsertClaudeHooks`, whose throwing read surfaces
331
+ // the parse error rather than clobbering the file.
332
+ //
333
+ // Preserving a `custom` set does NOT mean skipping the write. Passing
334
+ // `hookEvents` (== `wiredEvents` here) keeps exactly that set — nothing is
335
+ // added, and the prune loop finds no lorekit entry on the events outside it,
336
+ // so `removed` is always 0 — while still REFRESHING a stale command string.
337
+ // Skipping the call instead left a `--force` re-install unable to repair a
338
+ // hook command pointing at an old runner, which is the one thing a re-install
339
+ // is for.
340
+ const touchHooks = !skipHooksOnly && (hookEvents.length > 0 || wiredEvents.length > 0);
187
341
  let hooks = null;
188
- if (wireHooks) {
189
- hooks = upsertClaudeHooks(root, scope, resolveHookRunner());
342
+ if (touchHooks) {
343
+ hooks = upsertClaudeHooks(root, scope, resolveHookRunner(), hookEvents);
190
344
  }
191
345
 
192
346
  // Show global paths relative to ~ (a repo-relative path would be a mess of
@@ -207,16 +361,34 @@ export async function install(args) {
207
361
  }
208
362
  status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
209
363
 
210
- if (!wireHooks) {
211
- status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but memories are model-invoked only');
364
+ if (!touchHooks) {
365
+ status(
366
+ 'info',
367
+ 'hooks',
368
+ skipHooksOnly
369
+ ? 'skipped (--no-hooks) — the skills still work, but memory stays model-invoked'
370
+ : 'none — the skills still work, but memory stays model-invoked',
371
+ );
212
372
  } else {
213
- const n = hooks.added + hooks.updated;
373
+ const n = hooks.added + hooks.updated + hooks.removed;
214
374
  const hookParts = [
215
375
  hooks.added ? `${hooks.added} added` : '',
216
376
  hooks.updated ? `${hooks.updated} updated` : '',
377
+ hooks.removed ? `${hooks.removed} removed` : '',
217
378
  ].filter(Boolean);
218
379
  const hookState = n === 0 ? 'already wired' : hookParts.join(', ');
219
- status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
380
+ const wired = hookEvents.length > 0 ? ` (${hookEvents.join(', ')})` : '';
381
+ // The preserved-`custom` run DOES write, so it lands here rather than in the
382
+ // "left as-is" branch — but it is still the one state the three modes cannot
383
+ // express, so it keeps its own explanation of how to leave it.
384
+ const kept = preserveCustomHooks
385
+ ? `; a hand-wired set matching no preset, pass --hooks ${HOOK_MODES.join('|')} to change it`
386
+ : '';
387
+ status(
388
+ n === 0 ? 'info' : 'pass',
389
+ `hooks ${hookMode}`,
390
+ `${hookState} → ${display(hooks.file)}${wired}${kept}`,
391
+ );
220
392
  }
221
393
 
222
394
  const kind = tokenKind(token);
@@ -249,5 +421,8 @@ export async function install(args) {
249
421
  )}`,
250
422
  );
251
423
  }
252
- return 0;
424
+ // Bounded, non-PII: which of the three presets this run landed on. Counting
425
+ // the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
426
+ // user picks when actually asked, which is the whole point of the prompt.
427
+ return { exitCode: 0, 'lorekit.cli.hooks_mode': hookMode };
253
428
  }
package/src/link.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  parseOwnerArg,
26
26
  parseViewArg,
27
27
  parseRangeArg,
28
+ parseTagsArg,
28
29
  resolveScopeArg,
29
30
  surfaceFor,
30
31
  } from './deeplink-pure.mjs';
@@ -66,6 +67,7 @@ export async function link(args) {
66
67
  const owner = parseOwnerArg(args.owner);
67
68
  const view = parseViewArg(args.view);
68
69
  const range = parseRangeArg(args);
70
+ const tags = parseTagsArg(args.tags);
69
71
  const archived = Boolean(args.archived);
70
72
 
71
73
  const gaveAnyInput =
@@ -75,6 +77,7 @@ export async function link(args) {
75
77
  owner !== 'all' ||
76
78
  view !== 'scope' ||
77
79
  range !== null ||
80
+ tags.length > 0 ||
78
81
  archived;
79
82
 
80
83
  // Bare `lorekit link` (no scope, no lesson, no filters) → the cwd's
@@ -90,6 +93,7 @@ export async function link(args) {
90
93
  if (key) params.lesson = { scope, key };
91
94
  if (q) params.q = q;
92
95
  if (owner !== 'all') params.owner = owner;
96
+ if (tags.length) params.tags = tags;
93
97
  if (view !== 'scope') params.view = view;
94
98
  if (range !== null) params.range = range;
95
99
  if (archived) params.archived = true;
package/src/store/ttl.mjs CHANGED
@@ -11,6 +11,14 @@
11
11
  // `ttl_days` when both are supplied (the RPC's tri-state precedence).
12
12
  // - a read filters an expired row out lazily (there is no purge daemon
13
13
  // offline), exactly as the remote read paths do.
14
+ //
15
+ // `resolveDefaultTtlDays` is the one piece here with NO server counterpart, by
16
+ // design. It answers "what TTL did the user configure for a write that named
17
+ // none?" — a client-side policy question. The server contract is untouched:
18
+ // omitting `ttl_*` on `memory.write` still means the row is permanent, so an
19
+ // agent talking straight to the MCP endpoint is unaffected by a config file it
20
+ // cannot see. That asymmetry is deliberate; moving the default server-side would
21
+ // silently change what "omitted" means for every existing caller.
14
22
 
15
23
  export const TTL_MIN_DAYS = 1;
16
24
  export const TTL_MAX_DAYS = 365;
@@ -55,6 +63,76 @@ export function isLive(entry, now = new Date()) {
55
63
  return !entry.archived_at && !isExpired(entry.expires_at, now);
56
64
  }
57
65
 
66
+ // The DEFAULT TTL for a write that named none, resolved from the config layers
67
+ // (`ttl.default` and `scope.defaults.<prefix>.ttl_days` — see control.mjs).
68
+ //
69
+ // Returns the number of days, or null for "no default; the memory is permanent".
70
+ //
71
+ // Two rules that matter more than they look:
72
+ //
73
+ // 1. LONGEST MATCHING PREFIX WINS, not first-declared. `scope.defaults` is a
74
+ // plain object, so declaration order is whatever the author's editor left
75
+ // behind; a `branch::` entry and a `branch::owner/repo::` entry must resolve
76
+ // deterministically, and the more specific one is the one the author meant.
77
+ // (`tagsHint` UNIONS every match instead — correct there, because tags
78
+ // accumulate and a TTL cannot.)
79
+ // 2. AN EXPLICIT `null` MEANS PERMANENT and outranks `ttl.default`. Without it
80
+ // a repo-wide default could not be switched off for the one scope that
81
+ // holds durable lore, and `"ttl_days": null` is the only honest spelling of
82
+ // "keep this forever" — omitting the key has to keep meaning "inherit".
83
+ //
84
+ // Total by contract: a malformed config (fractional days, a string, out of
85
+ // range, a non-object entry) yields null rather than throwing. A config file is
86
+ // not a caller assertion the way `--ttl-days` is — it is ambient state that must
87
+ // never be able to break an unrelated write, the same posture the hook engine
88
+ // takes toward the host agent.
89
+ export function resolveDefaultTtlDays(scope, { ttlDefault = null, scopeDefaults = null } = {}) {
90
+ if (typeof scope === 'string' && scope && scopeDefaults && typeof scopeDefaults === 'object') {
91
+ let bestPrefix = null;
92
+ let bestValue;
93
+ for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
94
+ if (!cfg || typeof cfg !== 'object' || !('ttl_days' in cfg)) continue;
95
+ if (!matchesScopePrefix(scope, prefix)) continue;
96
+ if (bestPrefix !== null && prefix.length <= bestPrefix.length) continue;
97
+ bestPrefix = prefix;
98
+ bestValue = cfg.ttl_days;
99
+ }
100
+ if (bestPrefix !== null) {
101
+ if (bestValue === null) return null; // explicit "permanent" for this scope
102
+ return safeTtlDays(bestValue);
103
+ }
104
+ }
105
+ return safeTtlDays(ttlDefault);
106
+ }
107
+
108
+ // Whether a write's resolved scope falls under a `scope.defaults` key. An exact
109
+ // match, or a `::`-delimited descendant — so `repo::owner` never captures
110
+ // `repo::owner-other/x`. Shared with the nudge's tags hint so the two cannot
111
+ // disagree about what "this scope is configured" means.
112
+ export function matchesScopePrefix(scope, prefix) {
113
+ if (typeof scope !== 'string' || typeof prefix !== 'string' || !prefix) return false;
114
+ if (scope === prefix) return true;
115
+ return scope.startsWith(prefix.endsWith('::') ? prefix : prefix + '::');
116
+ }
117
+
118
+ // parseTtlDays, but a rejected value degrades to null instead of throwing.
119
+ //
120
+ // The type guard is not redundant with parseTtlDays: that one coerces with
121
+ // Number(), which maps `true` to 1 and `[]` to 0 — fine for a flag the user
122
+ // typed (a CLI flag is always a string), a footgun for a JSON value where `true`
123
+ // is a plausible typo for "yes, expire these" and would silently mean ONE DAY.
124
+ // Only a number or a numeric string is a TTL here.
125
+ function safeTtlDays(value) {
126
+ if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== '')) {
127
+ return null;
128
+ }
129
+ try {
130
+ return parseTtlDays(value);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
58
136
  // Resolve a write's `expires_at` from the tri-state TTL inputs, mirroring
59
137
  // memory_write (00030/00031): `clearTtl` wins (→ permanent, and `ttlDays` is
60
138
  // never even validated); else a supplied `ttlDays` sets expiry from `now`; else
package/src/write.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  // --tags <a,b,c> Comma-separated tag list (default: no tags)
14
14
  // --source-agent <name> Which agent recorded this lesson (default: none)
15
15
  // --trigger <slug> What prompted the write (default: none)
16
- // --ttl-days <n> Days until the memory auto-expires (1–365)
16
+ // --ttl-days <n> Days until the memory auto-expires (1–365). When
17
+ // omitted, a configured default may apply — see below.
17
18
  // --clear-ttl Remove any existing expiry (make the memory permanent)
18
19
  // --org <slug> Write to this org (remote only)
19
20
  //
@@ -27,6 +28,15 @@
27
28
  // --origin-pr <n> The pull request this lesson came out of
28
29
  // --no-origin Record no provenance at all
29
30
  //
31
+ // Default TTL. A write that passes neither --ttl-days nor --clear-ttl picks up
32
+ // whatever the config layers configured for its scope (`ttl.default` and
33
+ // `scope.defaults.<prefix>.ttl_days`; see control.mjs). Precedence is explicit
34
+ // flag > config > permanent, so a flag is always the last word and --clear-ttl
35
+ // is how you say "permanent" against a repo that defaults to expiring. The
36
+ // resolved source is reported in the confirmation line and on the telemetry
37
+ // span, because a TTL nobody typed is exactly the kind of thing that should
38
+ // never be silent.
39
+ //
30
40
  // Store targeting (default: remote if configured, else local):
31
41
  // --remote Force write to the remote store
32
42
  // --local Force write to the local offline store
@@ -39,12 +49,12 @@
39
49
  // false = updated) when the remote reports it.
40
50
  import process from 'node:process';
41
51
  import { resolveProjectRoot } from './config.mjs';
42
- import { resolveDenies } from './control.mjs';
52
+ import { loadControl, resolveDenies } from './control.mjs';
43
53
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
44
54
  import { log, err, heading, status, c } from './util.mjs';
45
55
  import { parseScopeKey } from './lessons-view.mjs';
46
56
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
47
- import { parseTtlDays } from './store/ttl.mjs';
57
+ import { parseTtlDays, resolveDefaultTtlDays } from './store/ttl.mjs';
48
58
 
49
59
  // Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
50
60
  function readStdin() {
@@ -132,6 +142,34 @@ export async function write(args) {
132
142
  }
133
143
  }
134
144
  const clearTtl = Boolean(args['clear-ttl']);
145
+
146
+ // Neither flag given → fall back to the scope's configured default, if any.
147
+ // `--clear-ttl` deliberately suppresses it: "make this permanent" has to mean
148
+ // permanent, not "permanent unless the repo config disagrees". Config is read
149
+ // through the same loadControl the hooks use, so the default the nudge advises
150
+ // and the default this command applies can never diverge.
151
+ let ttlSource = ttlDays ? 'flag' : 'none';
152
+ if (ttlDays === undefined && !clearTtl) {
153
+ const configured = resolveDefaultTtlDays(scope, loadControl(root, { env }));
154
+ if (configured != null) {
155
+ ttlDays = configured;
156
+ ttlSource = 'config';
157
+ }
158
+ }
159
+
160
+ // What gets REPORTED is the outcome, not the input. `--clear-ttl` beats
161
+ // `--ttl-days` inside resolveExpiresAt (and in memory_write, migrations
162
+ // 00030/00031), so `--ttl-days 7 --clear-ttl` persists a permanent row —
163
+ // yet ttlDays/ttlSource still described the flag the user typed, so the
164
+ // human output claimed "expires in 7 days" and --json reported
165
+ // ttl_days 7 / ttl_source "flag" for a row whose expires_at is null.
166
+ // Kept separate from ttlDays on purpose: writeArgs below spreads
167
+ // `...(ttlDays ? { ttl_days } : {})`, and nulling ttlDays itself would
168
+ // silently stop sending ttl_days to the remote RPC — a wire change nobody
169
+ // asked for. The precedence lives in one place; this only mirrors it.
170
+ const reportedTtlDays = clearTtl ? null : (ttlDays ?? null);
171
+ const reportedTtlSource = clearTtl ? 'none' : ttlSource;
172
+
135
173
  const orgSlug = typeof args.org === 'string' ? args.org : undefined;
136
174
 
137
175
  // ── Provenance ────────────────────────────────────────────────────────────
@@ -254,6 +292,8 @@ export async function write(args) {
254
292
  tags,
255
293
  source_agent: sourceAgent || null,
256
294
  trigger: trigger || null,
295
+ ttl_days: reportedTtlDays,
296
+ ttl_source: reportedTtlSource,
257
297
  origin,
258
298
  }, null, 2));
259
299
  } else {
@@ -263,6 +303,13 @@ export async function write(args) {
263
303
  log(` ${c.dim('scope')} ${scope}`);
264
304
  log(` ${c.dim('key')} ${key}`);
265
305
  if (tags.length) log(` ${c.dim('tags')} ${tags.join(', ')}`);
306
+ // Name the source. A TTL the caller typed needs no explanation; one that came
307
+ // from a config file two directories up does, or the first surprise is a
308
+ // memory that quietly vanished.
309
+ if (reportedTtlDays) {
310
+ const suffix = reportedTtlSource === 'config' ? c.dim(' (from config)') : '';
311
+ log(` ${c.dim('expires')} in ${reportedTtlDays} day${reportedTtlDays === 1 ? '' : 's'}${suffix}`);
312
+ }
266
313
  status('pass', verb, `${scope}::${key}`);
267
314
  log('');
268
315
  }
@@ -272,7 +319,8 @@ export async function write(args) {
272
319
  'lorekit.cli.write.store': storeName,
273
320
  'lorekit.cli.write.inserted': inserted,
274
321
  'lorekit.cli.write.has_tags': tags.length > 0,
275
- 'lorekit.cli.write.has_ttl': Boolean(ttlDays),
322
+ 'lorekit.cli.write.has_ttl': Boolean(reportedTtlDays),
323
+ 'lorekit.cli.write.ttl_source': reportedTtlSource,
276
324
  'lorekit.cli.write.clear_ttl': clearTtl,
277
325
  };
278
326
  }