@lorekit/cli 1.28.0 → 1.29.1

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,49 @@ 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
+
113
+ **Replacing a token.** A plain re-run reuses the token already in your config.
114
+ An interactive `lorekit install --force` instead asks what to do with it —
115
+ **keep**, **replace** (paste a new one), or **remove** — so a revoked token can
116
+ be swapped without hand-editing `.mcp.json` / `~/.claude.json`. The stored token
117
+ is only ever shown masked (`lk_rw_…ijkl`). Non-interactive runs (`--yes`, or no
118
+ TTY) never prompt and keep reusing the stored token; pass `--token` to replace
119
+ it in a script.
120
+
84
121
  > The hook command uses a global `lorekit` when one is on your `PATH` (fast),
85
122
  > otherwise `npx -y @lorekit/cli`. Installing the CLI globally
86
123
  > (`npm i -g @lorekit/cli`) is recommended so hooks fire without an npx
@@ -98,7 +135,9 @@ Verifies the setup and prints a status report:
98
135
  gitignored
99
136
  - for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not
100
137
  the `<project-ref>` placeholder), the token and its permission tier
101
- (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), and that the endpoint is reachable
138
+ (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), that the endpoint is reachable, and —
139
+ the `authentication` check — that the token is **still accepted by the
140
+ server**
102
141
  - for `off`: a note that memory is disabled
103
142
  - the git-derived read/write scopes for the current directory
104
143
 
@@ -109,6 +148,22 @@ lorekit doctor --deep # also does a write → read → delete round-trip (ne
109
148
 
110
149
  Exit code is non-zero if any check fails, so it fits CI gates.
111
150
 
151
+ **`connectivity` and `authentication` are different questions.** `connectivity`
152
+ probes the public `/health` function: it proves the network path and says
153
+ nothing about your credential. `authentication` makes one authenticated,
154
+ side-effect-free request and reports what the server said about the token
155
+ itself:
156
+
157
+ | Result | Meaning |
158
+ | --- | --- |
159
+ | `PASS — token accepted` | the token is live (read access confirmed) |
160
+ | `PASS — no read permission` | accepted, but it is a write-only `lk_wo_*` token |
161
+ | `FAIL — token REJECTED (HTTP 401)` | revoked, deleted, or never valid — every remote read and write is broken |
162
+ | `WARN` | rate limited, unreachable, or an inconclusive answer — never reported as "revoked" |
163
+
164
+ A revoked token is a **failure**, not a warning: fix it by creating a new token
165
+ and running `lorekit install --force`, which offers to replace the stored one.
166
+
112
167
  ### `lorekit list` (alias `ls`)
113
168
 
114
169
  Shows the lessons that apply to **where you are** — the scopes `deriveScope`
@@ -509,13 +564,20 @@ Both files share this schema — all fields optional:
509
564
  // tags appended to every memory.write from this repo/user
510
565
  // both layers merged: repo tags first, then user tags
511
566
 
567
+ "ttl.default": 90,
568
+ // days until a write that named no TTL expires
569
+ // repo wins over user (a scalar policy cannot merge)
570
+ // omit for the historical behaviour: memories are permanent
571
+
512
572
  "scope.defaults": {
513
573
  "repo::owner/name": { "tags": ["team"] },
514
- "branch::owner/name::": { "tags": ["ephemeral"] }
574
+ "branch::owner/name::": { "tags": ["ephemeral"], "ttl_days": 14 },
575
+ "global": { "ttl_days": null }
515
576
  },
516
- // per-scope tag defaults applied to writes whose scope
577
+ // per-scope tag and TTL defaults applied to writes whose scope
517
578
  // starts with the key; matched by prefix (no wildcards needed)
518
579
  // repo config only — this is a team-level write policy
580
+ // ttl_days: null means "permanent", overriding ttl.default
519
581
 
520
582
  // ── Hook behaviour ─────────────────────────────────────────────────────────
521
583
  "hooks.disabled": ["Stop"],
@@ -579,6 +641,58 @@ cannot override**:
579
641
  `lorekit doctor` to see the resolved mode, **which source decided it**, and any
580
642
  active deny constraints.
581
643
 
644
+ ### Default TTL
645
+
646
+ A memory with no TTL never expires. That is still the out-of-the-box behaviour,
647
+ and it is the right default for a lesson someone deliberately curated — but it is
648
+ the wrong one for the steady drip of observations a hook nudges an agent into
649
+ writing at the end of every run. `ttl.default` and
650
+ `scope.defaults.<prefix>.ttl_days` let a repo say how long its lore stays fresh.
651
+
652
+ **Precedence, most specific first:**
653
+
654
+ | # | Source | Wins because |
655
+ | - | ------ | ------------ |
656
+ | 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. |
657
+ | 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. |
658
+ | 3 | `ttl.default` | The repo-wide (or user-wide) fallback. |
659
+ | 4 | No expiry | Nothing configured. |
660
+
661
+ Prefix matching is `::`-delimited, so `branch::` covers every branch scope while
662
+ `repo::owner` does **not** cover `repo::owner/name` — `owner/name` is a single
663
+ segment. Tags from `scope.defaults` union across every matching prefix; a TTL
664
+ cannot, so exactly one entry wins.
665
+
666
+ A configured TTL that is out of range (or not a number) is **ignored** — the
667
+ write succeeds with no expiry rather than failing. Config is ambient state that
668
+ must never break an unrelated write; `--ttl-days 999`, by contrast, is a usage
669
+ error, because you typed it. `lorekit write` names the source in its output:
670
+
671
+ ```
672
+ expires in 90 days (from config)
673
+ ```
674
+
675
+ **Two limits worth knowing.** First, this is a **client-side** default: the
676
+ hosted `memory.write` contract is unchanged, so omitting `ttl_*` there still
677
+ means permanent. An agent talking straight to the MCP endpoint never sees your
678
+ config file. Second, a **hook cannot apply it** — hooks only read lore and emit
679
+ text; the write happens afterwards, in the agent's context. So the nudges instead
680
+ *advise* the resolved number:
681
+
682
+ ```
683
+ LoreKit: hit any friction worth remembering … Set ttl_days: 90 (this scope's
684
+ configured default) unless the lesson is durable enough to keep forever.
685
+ ```
686
+
687
+ That is advice, not enforcement. An agent that ignores it writes a permanent
688
+ memory, exactly as before.
689
+
690
+ **Refresh on update is free.** `memory_write` refreshes `expires_at` only when a
691
+ `ttl_*` is supplied, so re-writing the same `scope`+`key` with the default
692
+ applied slides the window forward — a lesson that keeps recurring keeps living,
693
+ and one nobody has seen in 90 days decays. Expired rows are swept nightly, which
694
+ also returns their headroom against the plan's memory cap.
695
+
582
696
  ## Options
583
697
 
584
698
  | Flag | Meaning |
@@ -594,7 +708,8 @@ active deny constraints.
594
708
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
595
709
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
596
710
  | `-y, --yes` | Non-interactive / apply; never prompt |
597
- | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
711
+ | `--hooks <mode>` | Lifecycle hooks to wire: `all` / `read-only` / `none` (`install`; `none` removes any already wired) |
712
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only. Leaves already-wired hooks alone (`install`) |
598
713
  | `--force` | Overwrite existing skill files (`install`) |
599
714
  | `--deep` | Write/read/delete round-trip (`doctor`) |
600
715
  | `--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
@@ -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
@@ -575,7 +592,7 @@ ${c.bold('Options')}
575
592
  // typo like `--gloabl` should fail loudly, not quietly fall back to --project.
576
593
  const KNOWN_FLAGS = [
577
594
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
578
- 'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
595
+ 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
579
596
  'event', 'json', 'scope', 'threshold', 'help', 'version',
580
597
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
581
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.28.0",
3
+ "version": "1.29.1",
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 (