@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 +121 -6
- package/bin/lorekit.mjs +24 -7
- package/package.json +1 -1
- package/src/config.mjs +101 -15
- package/src/control.mjs +55 -2
- package/src/core/lessons.mjs +25 -6
- package/src/doctor.mjs +89 -39
- package/src/install.mjs +268 -25
- package/src/store/remote.mjs +54 -0
- package/src/store/ttl.mjs +78 -0
- package/src/write.mjs +52 -4
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; `--
|
|
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_*`),
|
|
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
|
-
| `--
|
|
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
|
-
|
|
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
|
|
166
|
-
hooks
|
|
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
|
-
--
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 (
|