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