@lorekit/cli 1.11.0 → 1.13.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
@@ -51,9 +51,11 @@ without needing a marketplace:
51
51
  2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
52
52
  MCP config (preserving any other servers).
53
53
  3. **Hooks** — the *deterministic* layer: lessons injected on every
54
- `SessionStart`, a nudge on tool failure (`PostToolUseFailure`), and a
55
- retrospective nudge on `Stop`. These fire the shared `lorekit hook` engine
56
- and are merged into `settings.json` (existing hooks preserved).
54
+ `SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
55
+ look **relevant to that failure** ("you've hit this before") plus a nudge to
56
+ record the fix, and a retrospective nudge on `Stop`. These fire the shared
57
+ `lorekit hook` engine and are merged into `settings.json` (existing hooks
58
+ preserved).
57
59
 
58
60
  It first asks **where** to install:
59
61
 
@@ -212,10 +214,10 @@ rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
212
214
 
213
215
  ### `lorekit tree` (alias `resolve`)
214
216
 
215
- Show the scopes the hooks actually **inject** — branch → repo → global, in
216
- precedence order (most-specific first) — as a resolution hierarchy, and mark for
217
- any key present at more than one scope which scope's lesson **wins** and which are
218
- **shadowed**:
217
+ Show the scopes the hooks actually **inject** — project → branch → repo →
218
+ global, in precedence order (most-specific first) — as a resolution hierarchy,
219
+ and mark for any key present at more than one scope which scope's lesson **wins**
220
+ and which are **shadowed**:
219
221
 
220
222
  ```bash
221
223
  lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
@@ -224,12 +226,12 @@ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[]
224
226
  ```
225
227
 
226
228
  This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
227
- in `readOrder` (branch → repo → global) and keeps the first value seen per key, so
228
- a more-specific scope overrides a broader scope's same-key lesson. It answers
229
- "which lesson actually applies here, and what is being overridden?". Note that
230
- `project::` scope is **not** part of the injected set (the hooks never inject
231
- project lessons), so `tree` doesn't show it browse those with `lorekit list`.
232
- Each store is resolved independently, in the same Offline / Remote split.
229
+ in `readOrder` (project → branch → repo → global) and keeps the first value seen
230
+ per key, so a more-specific scope overrides a broader scope's same-key lesson. It
231
+ answers "which lesson actually applies here, and what is being overridden?". The
232
+ `project::` scope **is** part of the injected set (project is the most-specific
233
+ scope), so `tree`, the hooks, and every read command's `scopeList` now share one
234
+ ordering. Each store is resolved independently, in the same Offline / Remote split.
233
235
 
234
236
  ### `lorekit lint`
235
237
 
@@ -273,8 +275,9 @@ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store
273
275
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
274
276
  It is not run by hand — the plugins wire it into their hook config. It reads
275
277
  the host framework's JSON on stdin and prints that host's injection format on
276
- stdout (lessons at session start; a nudge on failure or at end of turn),
277
- always exiting 0 so it can never block the host agent.
278
+ stdout (lessons at session start; relevant lessons plus a write-nudge on a tool
279
+ failure; a retrospective nudge at end of turn), always exiting 0 so it can never
280
+ block the host agent.
278
281
 
279
282
  ```bash
280
283
  lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
@@ -474,9 +477,11 @@ active deny constraints.
474
477
  | `LOREKIT_TELEMETRY_TOKEN` | bearer token for telemetry export (overrides the baked-in default) |
475
478
  | `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` | override the telemetry OTLP endpoint / headers |
476
479
 
477
- ## What the skill does
480
+ ## What the skills do
478
481
 
479
- The installed `lorekit-memory` skill teaches an agent to:
482
+ `install` scaffolds two skills:
483
+
484
+ The **`lorekit-memory`** skill teaches an agent to:
480
485
 
481
486
  - **Read** scoped lessons at the start of a task, on first navigation into
482
487
  unfamiliar code, and before risky operations (narrow-to-broad scope merge).
@@ -488,11 +493,16 @@ This mirrors the read-on-start / write-on-failure loop of the `aw`
488
493
  autonomous-workflow agent. See the skill's own `SKILL.md` for the full
489
494
  protocol.
490
495
 
491
- The **skill** is model-invoked (the agent chooses to use it). For a
496
+ The **`lorekit-setup`** skill is the authoring counterpart: it teaches an agent
497
+ to wire a self-improvement loop into one of *your own* skills or workflows — the
498
+ two-tier model, the lesson bucket convention, and the entrenchment guards. See
499
+ its `SKILL.md` and `rules/self-improvement-loops.md`.
500
+
501
+ The **skills** are model-invoked (the agent chooses to use them). For a
492
502
  **deterministic** guarantee — lessons injected on every session start, a nudge
493
503
  on every tool failure — use the framework plugins in [`plugins/`](../../plugins/),
494
- which fire the `lorekit hook` engine on host lifecycle events. The skill and
495
- the hooks compose: hooks guarantee the *timing*, the skill supplies the
504
+ which fire the `lorekit hook` engine on host lifecycle events. The skills and
505
+ the hooks compose: hooks guarantee the *timing*, the skills supply the
496
506
  *authoring judgment*.
497
507
 
498
508
  ## Testing & validating across frameworks
package/bin/lorekit.mjs CHANGED
@@ -32,14 +32,14 @@ ${c.bold('Usage')}
32
32
  npx @lorekit/cli <command> [options]
33
33
 
34
34
  ${c.bold('Commands')}
35
- install Scaffold the lorekit-memory skill, wire the LoreKit MCP server,
36
- and install the deterministic hooks (lessons on SessionStart, a
37
- nudge on tool failure + at Stop). Prompts to install for this
38
- project (.claude) or globally for every project (~/.claude);
39
- --project / --global choose non-interactively, --no-hooks skips
40
- the hooks (skill stays model-invoked only).
41
- uninstall Reverse install: remove the lorekit-memory skill, the MCP server
42
- entry, and the lifecycle hooks for the chosen scope. Surgical —
35
+ install Scaffold the lorekit-memory + lorekit-setup skills, wire the
36
+ LoreKit MCP server, and install the deterministic hooks (lessons
37
+ on SessionStart, a nudge on tool failure + at Stop). Prompts to
38
+ install for this project (.claude) or globally for every project
39
+ (~/.claude); --project / --global choose non-interactively,
40
+ --no-hooks skips the hooks (skills stay model-invoked only).
41
+ uninstall Reverse install: remove the lorekit-memory + lorekit-setup skills,
42
+ the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
43
43
  other servers, hooks, and settings are left untouched. Prompts
44
44
  project vs global; --project / --global choose non-interactively.
45
45
  doctor Verify the skill install, MCP connectivity, token, and scope.
@@ -131,13 +131,14 @@ Run ${c.cyan('lorekit <command> --help')} for command-specific options.
131
131
  // focused entry instead of the full top-level HELP, so a user only sees the
132
132
  // flags that actually apply to what they're running.
133
133
  const COMMAND_HELP = {
134
- install: `${c.bold('lorekit install')} — scaffold the skill, wire the MCP server, install the hooks
134
+ install: `${c.bold('lorekit install')} — scaffold the skills, wire the MCP server, install the hooks
135
135
 
136
136
  ${c.bold('Usage')}
137
137
  npx @lorekit/cli install [options]
138
138
 
139
- Scaffolds the lorekit-memory skill, adds the LoreKit MCP server, and wires the
140
- deterministic hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
139
+ Scaffolds the lorekit-memory (runtime read/write) and lorekit-setup (loop
140
+ authoring) skills, adds the LoreKit MCP server, and wires the deterministic
141
+ hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
141
142
 
142
143
  ${c.bold('Options')}
143
144
  -d, --dir <path> Target project root (default: current directory)
@@ -159,8 +160,8 @@ ${c.bold('Examples')}
159
160
  ${c.bold('Usage')}
160
161
  npx @lorekit/cli uninstall [options]
161
162
 
162
- Removes the lorekit-memory skill, the MCP server entry, and the lifecycle hooks.
163
- Surgical — other servers, hooks, and settings are left untouched.
163
+ Removes the lorekit-memory and lorekit-setup skills, the MCP server entry, and
164
+ the lifecycle hooks. Surgical — other servers, hooks, and settings are left untouched.
164
165
 
165
166
  ${c.bold('Options')}
166
167
  -d, --dir <path> Target project root (default: current directory)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.11.0",
3
+ "version": "1.13.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": {
@@ -46,6 +46,12 @@ Both jobs run through LoreKit's `memory.*` MCP tools.
46
46
  If those tools are not connected, this skill is a no-op — say so once and
47
47
  continue the task; never block work because memory is unavailable.
48
48
 
49
+ > **Wiring a loop into your own skill?** This skill does the runtime read/write.
50
+ > To set up a *self-improvement loop* for a skill, workflow, or agent — so it
51
+ > reads its own lessons on every run and promotes proven ones into permanent
52
+ > rules — use the **`lorekit-setup`** skill, the authoring counterpart to this
53
+ > one.
54
+
49
55
  > **Modes.** Memory has a controllable backend (`lorekit doctor` shows the
50
56
  > resolved one): `remote` (the hosted LoreKit server — the default), `local`
51
57
  > (markdown files in two tiers — a per-user **home** tier at `~/.lorekit/` and
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: lorekit-setup
3
+ description: >
4
+ Sets up a self-improvement loop for a skill, workflow, or agent using LoreKit,
5
+ so a host gets better across runs by reading its own accumulated lessons at
6
+ the start of every run and hardening the proven ones into permanent rules.
7
+ Designs the two tiers (a fast episodic tier of LoreKit lessons, advisory-only;
8
+ a slow procedural tier that promotes a recurring lesson into a host rule),
9
+ chooses the lesson bucket (tag + key namespace) and scopes, and installs the
10
+ entrenchment guards that stop a learning loop from reinforcing its own
11
+ mistakes. Runtime reading and writing of lessons is the lorekit-memory skill;
12
+ this is the authoring counterpart. Use when giving a host durable cross-run
13
+ memory or wiring a lessons loop. Triggers on "set up memory for my skill",
14
+ "add a self-improvement loop", "give my workflow memory", "make this learn
15
+ from its mistakes", "self-improving memory", "/lorekit-setup".
16
+ user-invocable: true
17
+ argument-hint: '[host-name]'
18
+ license: MIT
19
+ metadata:
20
+ author: mthines
21
+ version: '1.0.0'
22
+ workflow_type: memory-loop-authoring
23
+ tags:
24
+ - lorekit
25
+ - self-improvement
26
+ - memory
27
+ - lessons
28
+ - loop
29
+ - authoring
30
+ - setup
31
+ ---
32
+
33
+ # LoreKit Setup
34
+
35
+ Give a skill, workflow, or agent a **self-improvement loop**: it reads its own
36
+ accumulated lessons at the start of every run and hardens the proven ones into
37
+ permanent rules — so it gets better the more it runs.
38
+
39
+ This is the **authoring** counterpart to `lorekit-memory`. `lorekit-memory` does
40
+ the runtime read/write of individual lessons; `lorekit-setup` wires the durable
41
+ loop that calls those primitives on a host's behalf. Both run on the same
42
+ LoreKit `memory.*` MCP tools.
43
+
44
+ ## The two tiers (in one screen)
45
+
46
+ | Tier | Mechanism | Changes behavior? |
47
+ | ---- | --------- | ----------------- |
48
+ | **Fast (episodic)** | LoreKit lessons in a per-host bucket, read at the start of a run, written on failure | **No** — advisory input only |
49
+ | **Slow (procedural)** | A human-reviewed edit that hardens a recurring lesson into a host rule | **Yes** |
50
+
51
+ A recurrence gate connects them: a lesson that recurs (`seen_count >= 3`) or is
52
+ marked `status=structural` becomes promotion-eligible. Entrenchment guards keep
53
+ the fast tier from reinforcing its own wrong conclusions.
54
+
55
+ ## Set up a loop
56
+
57
+ Follow [rules/self-improvement-loops.md](./rules/self-improvement-loops.md).
58
+ It covers: when to add a loop (and when not to), the bucket convention (tag
59
+ `loop::<host>-lessons` + key namespace), scope selection, the lesson schema, the
60
+ read/write steps, the promotion gate, the entrenchment guards, a wiring
61
+ checklist, and an interactive setup flow.
62
+
63
+ If invoked with a `host-name`, set up the loop for that host; otherwise ask
64
+ which skill / workflow / agent the loop is for, then walk the interactive setup.
65
+
66
+ The loop's runtime tier needs LoreKit's `memory.*` tools connected; if they are
67
+ not, the host's loop is a silent no-op (the slow tier — a normal source edit —
68
+ still works). Designing the loop needs no connection.
@@ -0,0 +1,257 @@
1
+ # Setting up a self-improvement loop
2
+
3
+ Use this when someone wants a skill, workflow, or agent to **get better across
4
+ runs** — "give my skill memory", "add a self-improvement loop", "make this
5
+ learn from its mistakes", "set up lessons for this workflow".
6
+
7
+ Reading and writing individual lessons at runtime is the **`lorekit-memory`**
8
+ skill (its `intake` / `retrospective` rules). This skill is one level up: how to
9
+ wire a **durable loop** into a host so it reads its own accumulated lessons at
10
+ the start of every run and records new ones on failure — safely, without the
11
+ loop entrenching its own mistakes. The loop runs on the same `memory.*` MCP
12
+ tools `lorekit-memory` uses.
13
+
14
+ The design has two tiers connected by a recurrence gate. Both run on LoreKit.
15
+
16
+ ## Contents
17
+
18
+ - [When to add a loop (and when not to)](#when-to-add-a-loop-and-when-not-to)
19
+ - [The two tiers](#the-two-tiers)
20
+ - [Conventions](#conventions)
21
+ - [Read step (start of every run)](#read-step-start-of-every-run)
22
+ - [Write step (on failure / at the end of a run)](#write-step-on-failure--at-the-end-of-a-run)
23
+ - [Promotion (fast → slow)](#promotion-fast--slow)
24
+ - [Entrenchment guards (do not skip these)](#entrenchment-guards-do-not-skip-these)
25
+ - [Wiring checklist](#wiring-checklist)
26
+ - [Interactive setup](#interactive-setup)
27
+
28
+ ---
29
+
30
+ ## When to add a loop (and when not to)
31
+
32
+ Add a loop when **all** of these hold:
33
+
34
+ - The host is an **orchestrator or multi-step pipeline** that can fail in
35
+ recurring, classifiable ways (wrong triage, a missed step, a false-green gate).
36
+ - Its failures are about the **host's own process**, not just the user's product.
37
+ - There is somewhere a proven lesson could eventually **harden into a rule** (the
38
+ slow tier below).
39
+
40
+ Do **not** add a loop to:
41
+
42
+ - **One-shot utilities** with no durable cross-run subject — the bookkeeping
43
+ costs more than it returns.
44
+ - **Adversarial / audit steps** (a red-team pass, a fresh-eyes reviewer) — they
45
+ must not be biased by prior runs; that is the whole point of a fresh pass.
46
+ - **Steps that handle secrets or credentials** — routing them through lesson
47
+ extraction is a leak risk.
48
+
49
+ When unsure, default to **no**. A loop can be added later; unwinding an
50
+ entrenched-bias loop is harder.
51
+
52
+ ---
53
+
54
+ ## The two tiers
55
+
56
+ | Tier | Mechanism | Storage | Changes behavior? | Gate |
57
+ | ---- | --------- | ------- | ----------------- | ---- |
58
+ | **Fast (episodic)** | LoreKit lessons (`memory.*`), read at the start of a run, written on failure / at the end | LoreKit | **No** — advisory input only | none (privacy pre-flight only) |
59
+ | **Slow (procedural)** | A permanent edit to the host's own source / rules | the host skill | **Yes** — a rule / gate / trigger | human review + approval |
60
+
61
+ The fast tier captures a lesson immediately and cheaply. A lesson earns a
62
+ **permanent** change to the host (the slow tier) only once it has **recurred
63
+ across runs** — recurrence is the cheap external signal that the lesson is real,
64
+ not a one-off. This is the episodic → procedural promotion path, with a human
65
+ gate on the slow tier so a single bad run can never rewrite the host.
66
+
67
+ The fast tier is optional: if LoreKit's `memory.*` tools are not connected, the
68
+ loop is a silent no-op (log one line, continue). The slow tier is just editing
69
+ the host and is unaffected.
70
+
71
+ ---
72
+
73
+ ## Conventions
74
+
75
+ Give the loop its own **bucket** so its lessons stay separate from every other
76
+ loop's on the same scopes. A bucket is a tag plus a key namespace:
77
+
78
+ - **Tag:** `loop::<host>-lessons` (e.g. `loop::deploy-lessons`). Reads filter by
79
+ it; writes always carry it.
80
+ - **Key:** `<host>-lessons::<kebab-slug>` (e.g. `deploy-lessons::migration-order`).
81
+ Same `scope` + `key` overwrites in place — the mechanism behind recurrence
82
+ counting.
83
+
84
+ Pick the **scope** per lesson from the standard LoreKit model (`::` is the only
85
+ separator; the `lorekit-memory` skill's scope-resolution reference has the full
86
+ derivation):
87
+
88
+ - **`global`** — universal lessons that should follow the user across every repo.
89
+ - **`repo::{owner}/{repo}`** — lessons specific to this repository.
90
+
91
+ Reserve `branch::` for throwaway notes; a loop normally writes `global` or
92
+ `repo::`.
93
+
94
+ ### The lesson record
95
+
96
+ A loop lesson is **procedural** ("how to do better next time"), not a fact about
97
+ the user. Keep the machine-read metadata in a `meta:` comment at the top of the
98
+ `value` so the prose stays readable:
99
+
100
+ ```markdown
101
+ <!-- meta: seen_count=1 status=active expires=<ISO 8601, ~90 days out> trigger-context="<concrete signal — file glob, task type, tool, error shape>" -->
102
+
103
+ # <one-line lesson title>
104
+
105
+ **What failed:** <concrete observable from the run>
106
+ **Why:** <root cause, if known; "unknown" is allowed>
107
+ **What to do next time:** <prescriptive, actionable, testable instruction>
108
+ **Promotion target:** <the host rule/step this would harden if promoted, or "none">
109
+ ```
110
+
111
+ `trigger-context` must be **concrete** (globs, task types, tool names, error
112
+ shapes) — never "when it feels relevant" — so the read step can match it
113
+ mechanically. `seen_count`, `status`, and `expires` drive recurrence, promotion,
114
+ and decay.
115
+
116
+ ---
117
+
118
+ ## Read step (start of every run)
119
+
120
+ Read narrow-to-broad, filtered by the bucket tag, and merge:
121
+
122
+ ```text
123
+ memory.list { scope: "repo::{owner}/{repo}", tags: ["loop::<host>-lessons"], limit: 50 } # skips silently if memory.* not connected
124
+ memory.list { scope: "global", tags: ["loop::<host>-lessons"], limit: 50 }
125
+ # when the run names a subsystem / error, add a search:
126
+ memory.search { q: "<keywords>", scopes: ["repo::{owner}/*", "global"], limit: 10 }
127
+ ```
128
+
129
+ Then:
130
+
131
+ 1. Match each lesson's `trigger-context` against the current run. Consider only
132
+ matches. **Skip any lesson whose `expires` is in the past** — treat it as
133
+ stale.
134
+ 2. Apply each matching *"What to do next time"* as a **consideration**, not a
135
+ command — it biases the run unless it conflicts with the user's stated intent
136
+ or a task-specific constraint. On conflict, the user's intent wins; surface it.
137
+ 3. On a `repo::` vs `global` collision, the `repo::` lesson wins (closer scope).
138
+
139
+ No consolidation pass is needed — LoreKit owns storage and deduplicates on write.
140
+
141
+ ---
142
+
143
+ ## Write step (on failure / at the end of a run)
144
+
145
+ Trigger on friction: a stuck loop, a repeated failure, a gate that should have
146
+ caught something, a near-miss, a guess that paid off. Not on smooth successes.
147
+
148
+ 1. **Classify the scope.** Trigger references a concrete repo path / repo-specific
149
+ package or term → `repo::{owner}/{repo}`. A glob, framework, tool, or task
150
+ type with no repo binding → `global`. When ambiguous, default to `global`.
151
+ 2. **Deduplicate**, so a recurrence updates in place instead of piling up:
152
+
153
+ ```text
154
+ memory.search { q: "<key words of the lesson>", scopes: ["repo::{owner}/{repo}", "global"], limit: 10 }
155
+ ```
156
+
157
+ 3. **Write** to the classified scope:
158
+
159
+ ```text
160
+ memory.write {
161
+ scope: "<global | repo::{owner}/{repo}>",
162
+ key: "<host>-lessons::<slug>",
163
+ value: "<the lesson body above>",
164
+ tags: ["loop::<host>-lessons", "source::<trigger>"],
165
+ trigger: "<stuck-loop | command-failure | gotcha | near-miss | assumption-wrong | paid-off | manual>"
166
+ }
167
+ ```
168
+
169
+ Same `scope` + `key` overwrites in place. **A recurrence resolves to an UPDATE
170
+ that increments `seen_count` by 1 and refreshes `expires`** — that is what makes
171
+ recurrence countable and drives promotion. If a lesson you applied at the start
172
+ of the run worked (the failure did not recur), still write the UPDATE:
173
+ successful application is recurrence evidence.
174
+
175
+ The privacy pre-flight is never skipped, autonomous or not: a candidate lesson
176
+ containing a secret, token, credential, or PII is **dropped, not written**. The
177
+ bar is stricter for `repo::` writes — a repo scope is team-visible.
178
+
179
+ ---
180
+
181
+ ## Promotion (fast → slow)
182
+
183
+ After a read or write, a lesson is **promotion-eligible** when either:
184
+
185
+ - `seen_count >= 3` — the same failure recurred across at least three runs, or
186
+ - it is tagged `status=structural` because it reflects a design gap, not a
187
+ one-off.
188
+
189
+ For an eligible lesson, **surface a one-line suggestion — never act silently**:
190
+
191
+ > Lesson "<title>" recurred N times. Promote it to a permanent guard in
192
+ > `<host>`? (edit the host's source / rules behind your normal review.)
193
+
194
+ The promotion target follows the lesson's scope: a `global` lesson hardens the
195
+ **host's own source** (every user of the host benefits); a `repo::` lesson
196
+ hardens the **repo's own rules / docs** (every teammate in that repo benefits).
197
+ Promotion is a normal, human-reviewed edit — LoreKit does not apply it. After a
198
+ successful promotion, write an UPDATE setting `status=promoted` so the lesson
199
+ stops re-suggesting and stands as an audit trail of why the rule exists.
200
+
201
+ ---
202
+
203
+ ## Entrenchment guards (do not skip these)
204
+
205
+ The dominant risk of any reflective-memory loop is **self-reinforcing error**:
206
+ the host wrongly concludes "approach X always fails", stores it, avoids X
207
+ forever, and never gathers the evidence to overturn the false belief. These
208
+ guards are what make the loop safe:
209
+
210
+ 1. **Lessons are advisory, never auto-applied to behavior.** A lesson biases a
211
+ run; it can never silently disable a gate, skip a step, or change a limit. The
212
+ only path from a lesson to changed behavior is the human-reviewed slow tier.
213
+ 2. **Recurrence gates promotion, not a single run** (`seen_count >= 3`, or an
214
+ explicit `status=structural` marker in the lesson's `meta:` comment).
215
+ 3. **Every lesson expires** (default ~90 days from last sighting; the read step
216
+ ignores expired lessons, so stale beliefs decay instead of entrenching).
217
+ 4. **Contradiction is surfaced, not silently overwritten** — the dedup search
218
+ finds the prior lesson; a genuine reversal is a reviewed decision.
219
+ 5. **The privacy pre-flight is never bypassed** — secrets / PII are dropped, not
220
+ stored.
221
+
222
+ ---
223
+
224
+ ## Wiring checklist
225
+
226
+ To add a loop to a host called `<host>`:
227
+
228
+ - [ ] Pick the bucket: tag `loop::<host>-lessons`, key `<host>-lessons::<slug>`.
229
+ - [ ] Add the **read step** at the start of the host's run (narrow-to-broad
230
+ `memory.list` filtered by the tag; apply matches as considerations; skip
231
+ expired).
232
+ - [ ] Add the **write step** at the host's existing failure / end-of-run points
233
+ (classify scope, `memory.search` to dedup, `memory.write`). No new
234
+ reflection stage — hook the points the host already detects.
235
+ - [ ] Add the **promotion suggestion** when a read/written lesson hits
236
+ `seen_count >= 3` or `status=structural`.
237
+ - [ ] State the **entrenchment guards** so a future maintainer does not "optimize
238
+ them away".
239
+ - [ ] Confirm the loop **degrades silently** when `memory.*` is not connected.
240
+
241
+ ---
242
+
243
+ ## Interactive setup
244
+
245
+ When a user asks to set up self-improving memory for a specific host, walk them
246
+ through it rather than dumping the whole recipe:
247
+
248
+ 1. **Confirm it should have a loop** — run the "when to add a loop" test above.
249
+ If the host is a one-shot utility, an adversarial pass, or a secret-handler,
250
+ say so and stop.
251
+ 2. **Name the bucket** — `loop::<host>-lessons` from the host's name.
252
+ 3. **Locate the read and write points** — the start of a run, and the failure /
253
+ end-of-run points the host already has. Show the two `memory.*` snippets with
254
+ the bucket filled in.
255
+ 4. **Write the guards down** in the host's own docs, so they survive future edits.
256
+ 5. **Do a dry run** — trigger a failure, confirm a lesson is written to the right
257
+ scope with the right tag, then confirm the next run reads it back.
package/src/config.mjs CHANGED
@@ -6,8 +6,17 @@ import { fileURLToPath } from 'node:url';
6
6
 
7
7
  // packages/cli/ — the installable package root (this file lives in src/).
8
8
  export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
9
- export const SKILL_SOURCE = path.join(PKG_ROOT, 'skill', 'lorekit-memory');
10
9
  export const SKILL_NAME = 'lorekit-memory';
10
+ export const SKILL_SOURCE = path.join(PKG_ROOT, 'skill', SKILL_NAME);
11
+
12
+ // Every skill the CLI ships. `lorekit-memory` is the operational read/write
13
+ // loop (kept first as the primary — `SKILL_NAME`/`SKILL_SOURCE` above alias it
14
+ // for back-compat); `lorekit-setup` is the authoring skill that wires a
15
+ // self-improvement loop into a host. install/uninstall/doctor iterate this list.
16
+ export const SKILLS = ['lorekit-memory', 'lorekit-setup'].map((name) => ({
17
+ name,
18
+ source: path.join(PKG_ROOT, 'skill', name),
19
+ }));
11
20
 
12
21
  export function resolveProjectRoot(dir) {
13
22
  return path.resolve(dir || process.cwd());
@@ -62,9 +71,9 @@ export function mcpConfigPath(root, scope = 'project') {
62
71
  // Where the skill is scaffolded for a given scope:
63
72
  // project → <root>/.claude/skills/… (this repo only)
64
73
  // global → ~/.claude/skills/… (personal skills, all projects)
65
- export function skillInstallDir(root, scope = 'project') {
74
+ export function skillInstallDir(root, scope = 'project', name = SKILL_NAME) {
66
75
  const base = scope === 'global' ? homeDir() : root;
67
- return path.join(base, '.claude', 'skills', SKILL_NAME);
76
+ return path.join(base, '.claude', 'skills', name);
68
77
  }
69
78
 
70
79
  // Claude Code settings file that holds the hooks for a given scope:
@@ -231,10 +240,10 @@ export function readLorekitServer(root) {
231
240
  // server / hook / setting intact, so uninstalling never damages a shared
232
241
  // ~/.claude.json or settings.json.
233
242
 
234
- // Delete the scaffolded skill directory. We own the whole
235
- // .claude/skills/lorekit-memory tree, so a recursive remove is safe.
236
- export function removeSkill(root, scope = 'project') {
237
- const dest = skillInstallDir(root, scope);
243
+ // Delete a scaffolded skill directory. We own the whole
244
+ // .claude/skills/<name> tree for each skill we ship, so a recursive remove is safe.
245
+ export function removeSkill(root, scope = 'project', name = SKILL_NAME) {
246
+ const dest = skillInstallDir(root, scope, name);
238
247
  const removed = fs.existsSync(dest);
239
248
  if (removed) fs.rmSync(dest, { recursive: true, force: true });
240
249
  return { dest, removed };
@@ -3,23 +3,52 @@
3
3
  // Storage is reached through the resolved store (local | remote), never a
4
4
  // backend directly, so the same read path serves every mode.
5
5
  import { deriveScope } from '../scope.mjs';
6
+ // The precedence merge and the literal substring matcher come from the
7
+ // dependency-free `lessons-pure.mjs` — the SAME primitives `tree` and `search`
8
+ // use, so the hook can't drift from them, and the hot path never pulls in the
9
+ // `lessons-view.mjs` render/`util` stack.
10
+ import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
6
11
 
7
12
  const MAX_LESSONS = 15;
13
+ // Cap on lessons injected on a failure — a small, focused "you've seen this
14
+ // before" set, never the whole applicable corpus.
15
+ const MAX_RELEVANT = 3;
16
+ // Cap on the terms distilled from a failure — bounds the relevance scan so a
17
+ // huge error blob can never turn into an unbounded match loop.
18
+ const MAX_TERMS = 12;
19
+ // Terms shorter than this are dropped: too generic to make a match meaningful.
20
+ const MIN_TERM_LEN = 4;
21
+ // Cap on how much failure text is scanned for terms. A tool can dump a
22
+ // multi-megabyte stderr/stdout; the salient error words are always near the
23
+ // front, so we bound the input BEFORE lowercasing/splitting it — otherwise a
24
+ // giant blob would materialise a giant token array (a CPU/memory spike) even
25
+ // though the term COUNT is capped. Generous enough to never clip a real error.
26
+ const MAX_SCAN_CHARS = 4096;
8
27
 
9
- // Read lessons narrow-to-broad through the store and merge; more specific
10
- // scope wins on key. Any per-scope failure is skipped (memory is best-effort).
28
+ // Read lessons narrow-to-broad through the store and resolve cross-scope
29
+ // precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
30
+ // more-specific-wins merge `tree` renders) — so the hook and `tree` provably
31
+ // can't drift. Any per-scope failure is skipped (memory is best-effort).
11
32
  export async function fetchLessons(store, cwd) {
12
33
  const scope = deriveScope(cwd);
13
- const byKey = new Map();
34
+ const groups = [];
14
35
  for (const s of scope.readOrder) {
15
36
  const res = await store.list({ scope: s, limit: 25 });
16
- if (!res || !res.ok) continue;
17
- const entries = Array.isArray(res.entries) ? res.entries : [];
18
- for (const e of entries) {
19
- if (e && e.key && !byKey.has(e.key)) byKey.set(e.key, { ...e, scope: s });
20
- }
37
+ if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
38
+ const entries = (Array.isArray(res.entries) ? res.entries : [])
39
+ .filter((e) => e && e.key)
40
+ .map((e) => ({ ...e, scope: s }));
41
+ groups.push({ scope: s, error: null, entries });
21
42
  }
22
- return { scope, lessons: [...byKey.values()].slice(0, MAX_LESSONS) };
43
+ // First value per key wins (most-specific scope, since `readOrder` is
44
+ // narrow→broad) — exactly what the old inline `byKey` merge did, now via the
45
+ // one shared resolver. The winners, in group order, are the injected set.
46
+ const { groups: resolved } = resolvePrecedence({ groups });
47
+ const lessons = [];
48
+ for (const g of resolved) {
49
+ for (const e of g.entries) if (e.winning) lessons.push(e);
50
+ }
51
+ return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
23
52
  }
24
53
 
25
54
  // Render lessons as a compact markdown block, or null when there are none.
@@ -37,6 +66,60 @@ export function formatLessons(lessons, scope) {
37
66
  return `${header}\n${body}`;
38
67
  }
39
68
 
69
+ // Distil a small set of significant, lowercased search TERMS from a tool
70
+ // failure — the tool name plus the salient words of its error text — used to
71
+ // look up lessons that might already cover this failure. Pure and total: any
72
+ // shape of `toolResponse` (object, string, null, malformed) is handled without
73
+ // throwing. Terms are de-duplicated, stopword- and length-filtered so a match
74
+ // stays meaningful, and the count is capped (`MAX_TERMS`) so a huge error blob
75
+ // can't blow up the downstream scan.
76
+ export function failureQuery(toolName, toolResponse) {
77
+ const text = `${toolName ? String(toolName) : ''} ${errorText(toolResponse)}`.slice(0, MAX_SCAN_CHARS);
78
+ const seen = new Set();
79
+ const terms = [];
80
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
81
+ if (raw.length < MIN_TERM_LEN || STOPWORDS.has(raw) || seen.has(raw)) continue;
82
+ seen.add(raw);
83
+ terms.push(raw);
84
+ if (terms.length >= MAX_TERMS) break;
85
+ }
86
+ return terms;
87
+ }
88
+
89
+ // Lessons whose key OR value literally contains ANY of the failure `terms`
90
+ // (case-insensitive, via the shared `matchesQuery` — never a regex), capped at
91
+ // `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
92
+ // falls back to the write-nudge alone). Preserves `lessons` order, so the
93
+ // most-specific scope's relevant lesson surfaces first.
94
+ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
95
+ if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
96
+ return [];
97
+ }
98
+ const out = [];
99
+ for (const l of lessons) {
100
+ if (terms.some((t) => matchesQuery(l, t))) out.push(l);
101
+ if (out.length >= cap) break;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Render the relevant-lessons block injected alongside the failure nudge, or
107
+ // null when nothing matched. Framed as prior art, not a directive — same
108
+ // "considerations, not rules" stance as `formatLessons`.
109
+ export function formatRelevantLessons(lessons) {
110
+ if (!lessons || lessons.length === 0) return null;
111
+ const header =
112
+ `LoreKit — you've hit something like this before. ${lessons.length} related ` +
113
+ 'lesson(s) (considerations, not rules; trust the current code if they conflict):';
114
+ const body = lessons
115
+ .map((l) => {
116
+ const first = String(l.value || '').split('\n')[0].slice(0, 300);
117
+ return `- (${l.scope}) ${l.key}: ${first}`;
118
+ })
119
+ .join('\n');
120
+ return `${header}\n${body}`;
121
+ }
122
+
40
123
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
41
124
  export function retrospectiveNudge(scope) {
42
125
  const writeScope = scope.repoScope || 'global';
@@ -58,3 +141,64 @@ export function failureNudge(toolName, scope) {
58
141
  `lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
59
142
  );
60
143
  }
144
+
145
+ // Pull the human-readable error text out of a tool_response of any shape. Total:
146
+ // null/primitive/string/object all yield a (possibly empty) string, never a
147
+ // throw — the relevance lookup is best-effort and must not break the host.
148
+ function errorText(response) {
149
+ if (response == null) return '';
150
+ if (typeof response === 'string') return response;
151
+ if (typeof response !== 'object') return String(response);
152
+ const parts = [];
153
+ for (const f of TEXT_FIELDS) {
154
+ const v = response[f];
155
+ if (typeof v === 'string') parts.push(v);
156
+ else if (v && typeof v === 'object') parts.push(safeStringify(v));
157
+ }
158
+ return parts.length ? parts.join(' ') : safeStringify(response);
159
+ }
160
+
161
+ function safeStringify(v) {
162
+ try {
163
+ return JSON.stringify(v) || '';
164
+ } catch {
165
+ return '';
166
+ }
167
+ }
168
+
169
+ // The tool_response fields that commonly carry error text, across frameworks.
170
+ const TEXT_FIELDS = [
171
+ 'stderr',
172
+ 'error',
173
+ 'message',
174
+ 'stdout',
175
+ 'output',
176
+ 'content',
177
+ 'reason',
178
+ 'detail',
179
+ 'details',
180
+ ];
181
+
182
+ // Generic words dropped from a failure query: too common to make a lesson match
183
+ // meaningful (they'd match almost anything). Kept small and hand-picked.
184
+ const STOPWORDS = new Set([
185
+ 'error',
186
+ 'errors',
187
+ 'failed',
188
+ 'failure',
189
+ 'tool',
190
+ 'call',
191
+ 'code',
192
+ 'exit',
193
+ 'with',
194
+ 'this',
195
+ 'that',
196
+ 'from',
197
+ 'null',
198
+ 'true',
199
+ 'false',
200
+ 'undefined',
201
+ 'command',
202
+ 'response',
203
+ 'status',
204
+ ]);
package/src/doctor.mjs CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import {
8
- SKILL_NAME,
8
+ SKILLS,
9
9
  resolveProjectRoot,
10
10
  skillInstallDir,
11
11
  readLorekitServer,
@@ -42,18 +42,24 @@ export async function doctor(args) {
42
42
  `v${process.versions.node}${major < 18 ? ' — need v18+ for fetch' : ''}`,
43
43
  );
44
44
 
45
- // 2. Skill installed — check BOTH the project and the global (~/.claude)
46
- // locations. `lorekit install --global` writes the skill under home, not the
47
- // repo, so a project-only check reports a healthy global install as "not
48
- // found" (exactly the false FAIL a --global setup would hit).
49
- const skillMd = [skillInstallDir(root, 'project'), skillInstallDir(root, 'global')]
50
- .map((dir) => path.join(dir, 'SKILL.md'))
51
- .find((p) => fs.existsSync(p));
52
- if (skillMd) {
53
- const rel = path.relative(root, skillMd);
54
- record('pass', `skill ${SKILL_NAME}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
55
- } else {
56
- record('fail', `skill ${SKILL_NAME}`, 'not found — run `lorekit install`');
45
+ // 2. Skills installed — check every skill the CLI ships, in BOTH the project
46
+ // and the global (~/.claude) locations. `lorekit install --global` writes the
47
+ // skill under home, not the repo, so a project-only check reports a healthy
48
+ // global install as "not found" (exactly the false FAIL a --global setup would
49
+ // hit).
50
+ for (const skill of SKILLS) {
51
+ const skillMd = [
52
+ skillInstallDir(root, 'project', skill.name),
53
+ skillInstallDir(root, 'global', skill.name),
54
+ ]
55
+ .map((dir) => path.join(dir, 'SKILL.md'))
56
+ .find((p) => fs.existsSync(p));
57
+ if (skillMd) {
58
+ const rel = path.relative(root, skillMd);
59
+ record('pass', `skill ${skill.name}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
60
+ } else {
61
+ record('fail', `skill ${skill.name}`, 'not found — run `lorekit install`');
62
+ }
57
63
  }
58
64
 
59
65
  // 3. Resolved control model — which mode, and who decided it.
package/src/hook.mjs CHANGED
@@ -7,7 +7,15 @@ import { resolveProjectRoot } from './config.mjs';
7
7
  import { deriveScope } from './scope.mjs';
8
8
  import { loadControl } from './control.mjs';
9
9
  import { createStore } from './store/index.mjs';
10
- import { fetchLessons, formatLessons, retrospectiveNudge, failureNudge } from './core/lessons.mjs';
10
+ import {
11
+ fetchLessons,
12
+ formatLessons,
13
+ retrospectiveNudge,
14
+ failureNudge,
15
+ failureQuery,
16
+ relevantLessons,
17
+ formatRelevantLessons,
18
+ } from './core/lessons.mjs';
11
19
  import { isFailure } from './core/failure.mjs';
12
20
  import { firstTimeThisSession } from './core/state.mjs';
13
21
  import { recordFixture } from './core/record.mjs';
@@ -92,7 +100,22 @@ async function run(args) {
92
100
  const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
93
101
  if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
94
102
  if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
95
- emit(failureNudge(parsed.toolName, scope));
103
+ // Best-effort: surface any existing lessons that look relevant to THIS
104
+ // failure ("you've hit this before"), then the write-nudge. A missing /
105
+ // unusable store, or no match, silently falls back to the nudge alone.
106
+ let relevant = null;
107
+ try {
108
+ const store = createStore(control);
109
+ if (store) {
110
+ const { lessons } = await fetchLessons(store, root);
111
+ const terms = failureQuery(parsed.toolName, parsed.toolResponse);
112
+ relevant = formatRelevantLessons(relevantLessons(lessons, terms));
113
+ }
114
+ } catch {
115
+ relevant = null; // never let a lesson lookup break the failure nudge
116
+ }
117
+ const nudge = failureNudge(parsed.toolName, scope);
118
+ emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
96
119
  return 0;
97
120
  }
98
121
 
package/src/install.mjs CHANGED
@@ -1,11 +1,10 @@
1
- // `lorekit install` — scaffold the skill and wire the MCP server.
1
+ // `lorekit install` — scaffold the skills and wire the MCP server.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import readline from 'node:readline';
5
5
  import process from 'node:process';
6
6
  import {
7
- SKILL_SOURCE,
8
- SKILL_NAME,
7
+ SKILLS,
9
8
  resolveProjectRoot,
10
9
  skillInstallDir,
11
10
  copyDir,
@@ -73,10 +72,13 @@ export async function install(args) {
73
72
  token = token || null;
74
73
  }
75
74
 
76
- // 3. Install the skill files.
77
- const dest = skillInstallDir(root, scope);
78
- const skillExisted = fs.existsSync(path.join(dest, 'SKILL.md'));
79
- const written = copyDir(SKILL_SOURCE, dest, { force: Boolean(args.force) });
75
+ // 3. Install the skill files — every skill the CLI ships.
76
+ const skillResults = SKILLS.map((skill) => {
77
+ const dest = skillInstallDir(root, scope, skill.name);
78
+ const existed = fs.existsSync(path.join(dest, 'SKILL.md'));
79
+ const written = copyDir(skill.source, dest, { force: Boolean(args.force) });
80
+ return { name: skill.name, dest, existed, written };
81
+ });
80
82
 
81
83
  // 4. Wire the MCP config for the chosen scope.
82
84
  const remoteUrl = buildRemoteUrl(endpoint, token);
@@ -100,12 +102,14 @@ export async function install(args) {
100
102
 
101
103
  // 5. Report.
102
104
  heading('Done');
103
- const skillState = !skillExisted
104
- ? 'installed'
105
- : written > 0
106
- ? `updated (${written} file(s) written)`
107
- : 'unchanged pass --force to overwrite';
108
- status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${display(dest)}`);
105
+ for (const s of skillResults) {
106
+ const skillState = !s.existed
107
+ ? 'installed'
108
+ : s.written > 0
109
+ ? `updated (${s.written} file(s) written)`
110
+ : 'unchanged pass --force to overwrite';
111
+ status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
112
+ }
109
113
  status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
110
114
 
111
115
  if (!wireHooks) {
@@ -0,0 +1,65 @@
1
+ // Pure, DEPENDENCY-FREE lesson primitives shared by the hook engine
2
+ // (`core/lessons.mjs`, the hot path) and the read-command view layer
3
+ // (`lessons-view.mjs`, which re-exports these). Kept in its own module so the
4
+ // hook can import the exact precedence + match logic the read commands use —
5
+ // one source of truth, no drift — WITHOUT dragging in the rendering/`util`
6
+ // stack (`heading`/`log`/`c`, plus the lint/dedupe/diff cores) that the rest of
7
+ // `lessons-view.mjs` carries. Zero imports on purpose.
8
+
9
+ // ── cross-scope precedence resolution (the `tree` + hook merge core) ──────────
10
+
11
+ // Given per-scope groups in RESOLUTION ORDER — most-specific first, exactly the
12
+ // order `deriveScope().readOrder` produces — compute which scope's lesson WINS
13
+ // each key and which are shadowed. This is the hook engine's merge: it iterates
14
+ // the scopes narrow-to-broad and keeps the FIRST value seen per key (`if
15
+ // (!winnerScopeByKey.has(key)) set`), so a more-specific scope shadows a broader
16
+ // scope's same-key lesson. Returns the same groups with every entry tagged
17
+ // `{ winning, shadowedBy }` (`shadowedBy` = the scope that won the key, or null
18
+ // for a winner), plus the resolved `winners` list (`{ scope, key }`, one per
19
+ // key) and winning/shadowed counts. A per-scope read error is passed through
20
+ // untouched (empty entries), so one unreadable scope never derails resolution.
21
+ // Pure — consumed by both `tree` (display) and `fetchLessons` (injection).
22
+ export function resolvePrecedence({ groups = [] } = {}) {
23
+ const winnerScopeByKey = new Map(); // key → the scope that first claimed it
24
+ const outGroups = [];
25
+ let winningTotal = 0;
26
+ let shadowedTotal = 0;
27
+ for (const g of groups) {
28
+ if (g.error) {
29
+ outGroups.push({ scope: g.scope, error: g.error, entries: [] });
30
+ continue;
31
+ }
32
+ const entries = [];
33
+ for (const e of g.entries || []) {
34
+ const prior = winnerScopeByKey.get(e.key);
35
+ if (prior === undefined) {
36
+ winnerScopeByKey.set(e.key, g.scope);
37
+ entries.push({ ...e, winning: true, shadowedBy: null });
38
+ winningTotal += 1;
39
+ } else {
40
+ entries.push({ ...e, winning: false, shadowedBy: prior });
41
+ shadowedTotal += 1;
42
+ }
43
+ }
44
+ outGroups.push({ scope: g.scope, error: null, entries });
45
+ }
46
+ const winners = [...winnerScopeByKey.entries()].map(([key, scope]) => ({ scope, key }));
47
+ return { groups: outGroups, winners, winningTotal, shadowedTotal };
48
+ }
49
+
50
+ // ── literal, case-insensitive substring matching (the `search` + hook core) ───
51
+
52
+ // Case-insensitive, LITERAL substring match of `query` against a normalized
53
+ // entry's key OR value — the `search` command's matcher, and the hook's
54
+ // failure-relevance matcher. Deliberately a plain `String.includes` (never
55
+ // `new RegExp(query)`) so a query full of regex metacharacters like `a.*(b)`
56
+ // matches those characters verbatim, never as a pattern. An empty query matches
57
+ // everything (the command guards emptiness as a usage error before ever calling
58
+ // this). Pure — trivially unit-testable.
59
+ export function matchesQuery(entry, query) {
60
+ const needle = String(query == null ? '' : query).toLowerCase();
61
+ if (!needle) return true;
62
+ const key = String(entry?.key ?? '').toLowerCase();
63
+ const value = String(entry?.value ?? '').toLowerCase();
64
+ return key.includes(needle) || value.includes(needle);
65
+ }
Binary file
package/src/scope.mjs CHANGED
@@ -44,7 +44,11 @@ export function deriveScope(cwd = process.cwd()) {
44
44
  : null;
45
45
  const projectScope = `project::${projectName}`;
46
46
 
47
- const readOrder = [branchScope, repoScope, 'global'].filter(Boolean);
47
+ // Injection / resolution order, most-specific → broadest. Matches the read
48
+ // commands' `scopeList` exactly (`lessons-view.mjs`) — project is FIRST, so a
49
+ // project-scoped lesson wins and IS injected. De-duplicated (a project whose
50
+ // basename collides with a scope, or a repo with no branch, never repeats).
51
+ const readOrder = [...new Set([projectScope, branchScope, repoScope, 'global'].filter(Boolean))];
48
52
 
49
53
  return {
50
54
  ownerRepo,
@@ -316,7 +316,7 @@ class TwoTierStore {
316
316
  const projRes = this.projectActive()
317
317
  ? await this.project.search({ q, scopes, tags })
318
318
  : { entries: [] };
319
- const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => `${e.scope}${e.key}`);
319
+ const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => `${e.scope}\x00${e.key}`);
320
320
  return { ok: true, entries: merged };
321
321
  }
322
322
 
package/src/tree.mjs CHANGED
@@ -5,17 +5,18 @@
5
5
  //
6
6
  // Precedence is not an assumption — it mirrors the hook engine exactly. The
7
7
  // SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
8
- // `deriveScope().readOrder` (branch → repo → global, most-specific first) and
9
- // keeps the FIRST value seen per key, so a more-specific scope shadows a broader
10
- // scope's same-key lesson. `tree` resolves over that same `readOrder` set via
11
- // the pure `resolvePrecedence`, so it shows the same resolution order the agent
12
- // is injected with (the hook additionally caps the injected set at MAX_LESSONS;
13
- // `tree` is uncapped, so a large workspace may list more winners than the hook
14
- // injects).
8
+ // `deriveScope().readOrder` (project → branch → repo → global, most-specific
9
+ // first) and keeps the FIRST value seen per key, so a more-specific scope
10
+ // shadows a broader scope's same-key lesson. `tree` resolves over that same
11
+ // `readOrder` set via the pure `resolvePrecedence`, so it shows the same
12
+ // resolution order the agent is injected with (the hook additionally caps the
13
+ // injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
14
+ // more winners than the hook injects).
15
15
  //
16
- // NOTE on scope coverage: `readOrder` is the injected set, and it deliberately
17
- // excludes `project::` the hooks never inject project-scope lessons, so `tree`
18
- // doesn't either (browse those with `lorekit list`). Both stores are resolved
16
+ // NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
17
+ // PR it INCLUDES `project::` (project is the most-specific scope and now wins /
18
+ // is injected), so `tree` shows it too the ordering is now unified across the
19
+ // hook, `tree`, and every read command's `scopeList`. Both stores are resolved
19
20
  // independently (precedence is per-store — the hook reads one resolved store),
20
21
  // in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
21
22
  // `traceCommand` by the bin.
@@ -24,7 +25,8 @@ import { resolveProjectRoot } from './config.mjs';
24
25
  import { deriveScope } from './scope.mjs';
25
26
  import { resolveDenies } from './control.mjs';
26
27
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
27
- import { gather, resolvePrecedence, preview, shortDate } from './lessons-view.mjs';
28
+ import { resolvePrecedence } from './lessons-pure.mjs';
29
+ import { gather, preview, shortDate } from './lessons-view.mjs';
28
30
  import { log, heading, status, c } from './util.mjs';
29
31
 
30
32
  export async function tree(args) {
package/src/uninstall.mjs CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import process from 'node:process';
7
7
  import {
8
- SKILL_NAME,
8
+ SKILLS,
9
9
  resolveProjectRoot,
10
10
  removeSkill,
11
11
  removeMcpServer,
@@ -51,7 +51,10 @@ export async function uninstall(args) {
51
51
  // install — never clobber what we can't understand), so we catch and report it
52
52
  // cleanly, leaving that file byte-for-byte untouched. Atomic writes
53
53
  // (writeFileAtomic) guarantee a config can't be half-written even on a crash.
54
- const skill = attempt(() => removeSkill(root, scope));
54
+ const skillSteps = SKILLS.map((skill) => ({
55
+ name: skill.name,
56
+ step: attempt(() => removeSkill(root, scope, skill.name)),
57
+ }));
55
58
  const mcp = attempt(() => removeMcpServer(root, scope));
56
59
  const hooks = attempt(() => removeClaudeHooks(root, scope));
57
60
 
@@ -61,10 +64,12 @@ export async function uninstall(args) {
61
64
  const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
62
65
 
63
66
  heading('Done');
64
- report(skill, `skill ${SKILL_NAME}`, {
65
- done: (r) => `removed ${display(r.dest)}`,
66
- noop: 'not installed nothing to remove',
67
- });
67
+ for (const { name, step } of skillSteps) {
68
+ report(step, `skill ${name}`, {
69
+ done: (r) => `removed ${display(r.dest)}`,
70
+ noop: 'not installed — nothing to remove',
71
+ });
72
+ }
68
73
  report(mcp, mcpLabel, {
69
74
  done: (r) => `lorekit server removed → ${display(r.file)}`,
70
75
  noop: 'no lorekit server entry — nothing to remove',
@@ -74,8 +79,10 @@ export async function uninstall(args) {
74
79
  noop: 'no lorekit hooks — nothing to remove',
75
80
  });
76
81
 
77
- const failed = [skill, mcp, hooks].some((s) => !s.ok);
78
- const any = (skill.result?.removed || mcp.result?.removed || hooks.result?.removed) && true;
82
+ const skillStepList = skillSteps.map((s) => s.step);
83
+ const failed = [...skillStepList, mcp, hooks].some((s) => !s.ok);
84
+ const any =
85
+ (skillStepList.some((s) => s.result?.removed) || mcp.result?.removed || hooks.result?.removed) && true;
79
86
 
80
87
  if (failed) {
81
88
  log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);