@lorekit/cli 1.70.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,6 +210,27 @@ itself:
210
210
  A revoked token is a **failure**, not a warning: fix it by creating a new token
211
211
  and running `lorekit install --force`, which offers to replace the stored one.
212
212
 
213
+ ### `lorekit update`
214
+
215
+ Offline refresh of the bundled skills (and their hook command string) to the
216
+ version shipped with the running CLI — the fix `doctor`'s outdated-skill
217
+ warning and the SessionStart drift nudge (`updates.notify`) both point at.
218
+ Fully offline: the shipped skill source travels in the same npm tarball as
219
+ this CLI, so "installed vs shipped" is a filesystem version compare, never a
220
+ network call.
221
+
222
+ ```bash
223
+ lorekit update # refresh every scope that already has an install
224
+ lorekit update --check # dry run: report drift, write nothing
225
+ lorekit update --project # only the project (.claude/skills) install
226
+ lorekit update --global # only the global (~/.claude/skills) install
227
+ ```
228
+
229
+ `update` never CREATES a fresh install — that stays `install`'s job, so a
230
+ scope with nothing installed is reported and left alone. Refreshing prunes
231
+ any file the shipped skill no longer ships before re-copying, so a
232
+ dropped rule file doesn't silently outlive the version that removed it.
233
+
213
234
  ### `lorekit list` (alias `ls`)
214
235
 
215
236
  Shows the lessons that apply to **where you are** — the scopes `deriveScope`
@@ -646,6 +667,75 @@ The server's refusal is printed verbatim with a one-line next step; the CLI make
646
667
  exactly one request and never retries, splits the sweep, or re-scopes around it.
647
668
  Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
648
669
 
670
+ ### `lorekit groom` / `lorekit policy` / `lorekit protect` (aliases: `pin` / `unpin`)
671
+
672
+ Retention automation — the CLI counterpart to the MCP `groom.*`/`policy.*`/
673
+ `memory.protect` tools and the REST `/groom/*`/`/policies*`/`/protect` routes.
674
+ **Remote only**, matching `purge`: `retention_policies` has no local-store
675
+ equivalent.
676
+
677
+ ```bash
678
+ # Preview what a rule would catch, without changing anything
679
+ lorekit groom --scope repo::acme/app --unseen-days 90
680
+
681
+ # Archive the matches (soft-archive, recoverable via `lorekit restore`)
682
+ lorekit groom --scope repo::acme/app --unseen-days 90 --run --yes
683
+
684
+ # Save it as a policy you can re-run by name, or let it sweep nightly
685
+ lorekit policy create --scope repo::acme/app --name "stale repo lore" \
686
+ --unseen-days 90 --mode review
687
+ lorekit policy list
688
+ lorekit policy update <id> --mode auto --enabled
689
+ lorekit policy delete <id>
690
+
691
+ # Exempt one lesson from every rule and every manual sweep
692
+ lorekit pin repo::acme/app::the-load-bearing-lesson
693
+ lorekit unpin repo::acme/app::the-load-bearing-lesson
694
+ ```
695
+
696
+ `groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes]` previews
697
+ by default; `--run` archives. Exactly one of `--policy-id`/`--scope` is
698
+ required. `policy <list|create|update|delete>` manages the SAVED rules
699
+ `--policy-id` runs; `delete` removes the rule only, never the lessons it
700
+ matched.
701
+
702
+ Every condition ANDs together: the five age/activity fields
703
+ (`--min-age-days`/`--unseen-days`/`--max-seen-count`/`--max-read-count`/
704
+ `--max-opened-count`) plus the same **eight dimension filters** the Lore
705
+ Explorer's filter bar offers —
706
+
707
+ | Flag | `--<dim>-mode` values (default) |
708
+ |------|----------------------------------|
709
+ | `--tags <a,b>` | `any` \| `all` \| `none` (`any`) |
710
+ | `--kind <a,b>` | `in` \| `nin` (`in`) |
711
+ | `--host <a,b>` | `in` \| `nin` (`in`) |
712
+ | `--trigger <a,b>` | `in` \| `nin` (`in`) |
713
+ | `--source-agent <a,b>` | `in` \| `nin` (`in`) |
714
+ | `--origin-repo <a,b>` | `in` \| `nin` (`in`) |
715
+ | `--origin-branch <a,b>` | `in` \| `nin` (`in`) |
716
+ | `--origin-pr <a,b>` | `in` \| `nin` (`in`) |
717
+
718
+ — all comma-separated, same convention as `write --tags a,b,c`:
719
+
720
+ ```bash
721
+ lorekit policy create --scope repo::acme/app --name "stale ci review events" \
722
+ --kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
723
+ --min-age-days 30
724
+ ```
725
+
726
+ `policy update` pairs every condition — age/count AND every dimension — with
727
+ a `--clear-<field>` boolean (e.g. `--clear-kind`, `--clear-min-age-days`) that
728
+ sends an explicit `null` rather than omitting the field: omitting a field on
729
+ update leaves it unchanged, `null` clears it.
730
+
731
+ ```bash
732
+ lorekit policy update <id> --clear-kind --host reviewer,aw
733
+ ```
734
+
735
+ `protect <scope::key> [--off]` (or the shorter `pin`/`unpin`) marks a lesson as
736
+ excluded from every policy and every `groom --run`, regardless of which rule
737
+ would otherwise match it — persists until explicitly cleared.
738
+
649
739
  ### `lorekit hook`
650
740
 
651
741
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -1110,8 +1200,8 @@ also returns their headroom against the plan's memory cap.
1110
1200
  | Flag | Meaning |
1111
1201
  |------|---------|
1112
1202
  | `-d, --dir <path>` | Target project root (default: cwd) |
1113
- | `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default) |
1114
- | `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`) |
1203
+ | `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default). Narrows to the project scope (`update`) |
1204
+ | `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`). Narrows to the global scope (`update`) |
1115
1205
  | `-e, --endpoint <url>` | LoreKit MCP endpoint |
1116
1206
  | `-t, --token <token>` | LoreKit token |
1117
1207
  | `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
@@ -1125,6 +1215,7 @@ also returns their headroom against the plan's memory cap.
1125
1215
  | `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
1126
1216
  | `--force` | Overwrite existing skill files (`install`) |
1127
1217
  | `--deep` | Write/read/delete round-trip (`doctor`) |
1218
+ | `--check` | Dry run: report skill-install drift without writing anything (`update`) |
1128
1219
  | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `invariants candidates` / `link` / `purge` / `purge-expired`) |
1129
1220
  | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory. On `show` / `write` it **names** the scope, overriding the positional |
1130
1221
  | `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
package/bin/lorekit.mjs CHANGED
@@ -39,6 +39,11 @@ ${c.bold('Commands')}
39
39
  other servers, hooks, and settings are left untouched. Prompts
40
40
  project vs global; --project / --global choose non-interactively.
41
41
  doctor Verify the skill install, remote connectivity, token, and scope.
42
+ update Offline refresh of the bundled skills (and their hook command
43
+ string) to the version shipped with the running CLI — the fix
44
+ for doctor's outdated-skill warning. Never creates a fresh
45
+ install (that's \`install\`'s job); --project / --global narrow
46
+ to one scope; --check reports drift without writing anything.
42
47
  list (ls) List the memories that apply to the current directory, split into
43
48
  an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
44
49
  section (the hosted LoreKit API). Groups by scope (project/branch/repo/global).
@@ -169,6 +174,7 @@ ${c.bold('Options')}
169
174
  --force Overwrite existing skill files (install)
170
175
  --deep Do a write→read→delete round-trip (doctor)
171
176
  --telemetry Verify the OTLP export credential works (doctor)
177
+ --check Report drift without writing anything (update)
172
178
  --adapter <name> Host framework for hook: claude | cursor | codex
173
179
  --event <name> Host hook event (else read from stdin payload)
174
180
  -h, --help Show this help
@@ -193,6 +199,7 @@ ${c.bold('Examples')}
193
199
  npx @lorekit/cli install --global # set up memory for every project (~/.claude)
194
200
  npx @lorekit/cli uninstall --global # tear that global setup back down
195
201
  npx @lorekit/cli doctor --deep
202
+ npx @lorekit/cli update --check # report outdated skills without writing
196
203
  npx @lorekit/cli migrate --from .lore # preview a rename
197
204
  npx @lorekit/cli migrate --from .lore --to project --yes
198
205
  npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
@@ -302,6 +309,32 @@ ${c.bold('Examples')}
302
309
  npx @lorekit/cli doctor --deep
303
310
  npx @lorekit/cli doctor --telemetry
304
311
  npx @lorekit/cli doctor --mode local
312
+ `,
313
+ update: `${c.bold('lorekit update')} — offline refresh of the bundled skills to the shipped version
314
+
315
+ ${c.bold('Usage')}
316
+ npx @lorekit/cli update [options]
317
+
318
+ Re-copies every bundled skill (force) into whichever scope(s) already have an
319
+ install, and refreshes that scope's hook command string — the same skill-copy
320
+ path and hook-command call \`install --force\` uses, so the two can never
321
+ disagree on what "installing a skill" means. Fully offline: the shipped skill
322
+ source travels in the same npm tarball as this running CLI, so "installed vs
323
+ shipped" is a filesystem version compare, not a network call. Never creates a
324
+ fresh install — that's \`install\`'s job — so a scope with nothing installed is
325
+ reported and left alone.
326
+
327
+ ${c.bold('Options')}
328
+ -d, --dir <path> Target project root (default: current directory)
329
+ --project Only refresh this project's install (.claude/skills)
330
+ --global Only refresh the global install (~/.claude/skills)
331
+ --check Dry run: report drift (installed vs shipped version
332
+ per skill) and write nothing
333
+
334
+ ${c.bold('Examples')}
335
+ npx @lorekit/cli update
336
+ npx @lorekit/cli update --check
337
+ npx @lorekit/cli update --global
305
338
  `,
306
339
  list: `${c.bold('lorekit list')} — list the memories that apply to the current directory ${c.dim('(alias: ls)')}
307
340
 
@@ -902,7 +935,10 @@ ${c.bold('Options')}
902
935
  ${c.bold('Usage')}
903
936
  lorekit groom --policy-id <id> [--run] [--yes] [--json]
904
937
  lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
905
- [--max-read-count <n>] [--max-opened-count <n>] [--run] [--yes] [--json]
938
+ [--max-read-count <n>] [--max-opened-count <n>]
939
+ [--tags a,b --tags-mode any|all|none]
940
+ [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
941
+ [--run] [--yes] [--json]
906
942
 
907
943
  Resolves the SAME candidates a saved policy or an inline condition set would
908
944
  catch, via the retention-policy candidate query — a previewed count always
@@ -927,6 +963,12 @@ ${c.bold('Options')}
927
963
  the hundreds — a small value matches nothing
928
964
  --max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
929
965
  times. Bulk reads do NOT count, so 0 means never chosen
966
+ --tags <a,b>, --tags-mode <any|all|none>
967
+ Match lessons carrying these labels (default: any)
968
+ --kind, --host, --trigger, --source-agent,
969
+ --origin-repo, --origin-branch, --origin-pr <a,b>
970
+ --<dim>-mode <in|nin> Match lessons whose dimension is one of these
971
+ values (default: in); comma-separated
930
972
  --run Archive the matches instead of previewing
931
973
  -y, --yes Confirm --run; required when non-interactive
932
974
  --json Machine-readable result
@@ -941,12 +983,17 @@ ${c.bold('Usage')}
941
983
  lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
942
984
  [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
943
985
  [--max-read-count <n>] [--max-opened-count <n>]
986
+ [--tags a,b --tags-mode any|all|none]
987
+ [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
944
988
  lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
945
- [--min-age-days <n>|--clear-min-age-days] [...] [--json]
989
+ [--min-age-days <n>|--clear-min-age-days] [...]
990
+ [--tags a,b|--clear-tags] [--<dim> a,b|--clear-<dim>] [--<dim>-mode in|nin] [--json]
946
991
  lorekit policy delete <id> [--yes] [--json]
947
992
 
948
993
  A policy is a saved retention rule: a scope plus AND-ed conditions
949
- (min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count).
994
+ (min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count,
995
+ plus the eight dimension filters — tags/kind/host/trigger/source-agent/
996
+ origin-repo/origin-branch/origin-pr).
950
997
  \`mode: review\` surfaces it for
951
998
  you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
952
999
  nightly, but ONLY once you also pass --enabled — auto starts disabled on
@@ -962,8 +1009,14 @@ ${c.bold('Options')}
962
1009
  --min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
963
1010
  --max-opened-count <n>
964
1011
  Conditions (create/update)
1012
+ --tags <a,b>, --tags-mode <any|all|none>
1013
+ --kind, --host, --trigger, --source-agent,
1014
+ --origin-repo, --origin-branch, --origin-pr <a,b>
1015
+ --<dim>-mode <in|nin> Dimension conditions (create/update); comma-separated
965
1016
  --clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
966
- --clear-max-read-count, --clear-max-opened-count
1017
+ --clear-max-read-count, --clear-max-opened-count,
1018
+ --clear-tags, --clear-kind, --clear-host, --clear-trigger,
1019
+ --clear-source-agent, --clear-origin-repo, --clear-origin-branch, --clear-origin-pr
967
1020
  Remove a condition (update only)
968
1021
  -y, --yes Confirm delete; required when non-interactive
969
1022
  --json Machine-readable result
@@ -1088,8 +1141,20 @@ const KNOWN_FLAGS = [
1088
1141
  'name', 'mode', 'enabled', 'disabled',
1089
1142
  'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
1090
1143
  'clear-max-opened-count', 'off',
1144
+ // The eight retention dimension filters' `*-mode` value flags (the value
1145
+ // flags themselves — tags/source-agent/trigger/kind/host/origin-repo/
1146
+ // origin-branch/origin-pr — are already listed above for `write`) plus
1147
+ // their `clear-*` booleans (policy update only). See
1148
+ // `shared/flags.mjs`'s `parseDimensionConditions`, the one place that
1149
+ // owns the flag-name ↔ field-name ↔ mode-enum table these mirror.
1150
+ 'tags-mode', 'source-agent-mode', 'trigger-mode', 'kind-mode', 'host-mode',
1151
+ 'origin-repo-mode', 'origin-branch-mode', 'origin-pr-mode',
1152
+ 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host',
1153
+ 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr',
1091
1154
  // `obligations`
1092
1155
  'files', 'strict', 'strict-all',
1156
+ // `update`
1157
+ 'check',
1093
1158
  ];
1094
1159
 
1095
1160
  async function main() {
@@ -1102,7 +1167,7 @@ async function main() {
1102
1167
  const argv = process.argv.slice(2);
1103
1168
  const args = parseArgs(argv, {
1104
1169
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
1105
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all'],
1170
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host', 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr', 'check'],
1106
1171
  known: KNOWN_FLAGS,
1107
1172
  });
1108
1173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.70.0",
3
+ "version": "1.71.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,7 +18,7 @@ argument-hint: '[scope-hint]'
18
18
  license: MIT
19
19
  metadata:
20
20
  author: mthines
21
- version: '1.0.0'
21
+ version: '1.1.0'
22
22
  workflow_type: shared-memory-grooming-and-consolidation
23
23
  tags:
24
24
  - lorekit
@@ -17,7 +17,7 @@ argument-hint: '[read|write] [scope-hint or lesson]'
17
17
  license: MIT
18
18
  metadata:
19
19
  author: mthines
20
- version: '1.0.0'
20
+ version: '1.1.0'
21
21
  workflow_type: shared-memory-intake-and-retrospective
22
22
  tags:
23
23
  - lorekit
@@ -7,7 +7,6 @@ import { execFileSync } from 'node:child_process';
7
7
  import {
8
8
  SKILLS,
9
9
  resolveProjectRoot,
10
- skillInstallDir,
11
10
  CLAUDE_HOOK_EVENTS,
12
11
  installedHookEvents,
13
12
  hookModeFromEvents,
@@ -17,6 +16,7 @@ import {
17
16
  tokenKind,
18
17
  readLorekitJson,
19
18
  } from '../shared/config.mjs';
19
+ import { checkSkillVersions } from '../shared/skill-versions.mjs';
20
20
  import { splitEndpoint } from '../shared/mcp.mjs';
21
21
  import {
22
22
  resolveTelemetryConfig,
@@ -71,18 +71,48 @@ export async function doctor(args) {
71
71
  // skill under home, not the repo, so a project-only check reports a healthy
72
72
  // global install as "not found" (exactly the false FAIL a --global setup would
73
73
  // hit).
74
+ //
75
+ // Beyond existence, each installed scope now reports its VERSION against the
76
+ // one this CLI ships (`checkSkillVersions` — an offline compare of
77
+ // `metadata.version` in SKILL.md's frontmatter, see `shared/skill-versions.mjs`).
78
+ // An `outdated` or `unknown` (legacy, unparseable) install is a `warn`, never a
79
+ // `fail` — the skill still works, it just has no signal to update without this
80
+ // line — and names the fix scope-aware, mirroring the hooks-upgrade wording
81
+ // above: `lorekit update` is what a stale install is missing, the same way
82
+ // `lorekit install --hooks <mode>` is what a stale hook wiring is missing.
83
+ const skillVersions = checkSkillVersions(root);
74
84
  for (const skill of SKILLS) {
75
- const skillMd = [
76
- skillInstallDir(root, 'project', skill.name),
77
- skillInstallDir(root, 'global', skill.name),
78
- ]
79
- .map((dir) => path.join(dir, 'SKILL.md'))
80
- .find((p) => fs.existsSync(p));
81
- if (skillMd) {
82
- const rel = path.relative(root, skillMd);
83
- record('pass', `skill ${skill.name}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
84
- } else {
85
+ const installedEntries = skillVersions.filter(
86
+ (r) => r.name === skill.name && r.state !== 'not-installed',
87
+ );
88
+ if (installedEntries.length === 0) {
85
89
  record('fail', `skill ${skill.name}`, 'not found — run `lorekit install`');
90
+ continue;
91
+ }
92
+ for (const entry of installedEntries) {
93
+ const rel = path.relative(root, entry.installedPath);
94
+ const displayPath = rel && !rel.startsWith('..') ? rel : prettyPath(entry.installedPath);
95
+ // Only distinguish the scope in the label when the skill is installed in
96
+ // more than one — the common single-scope case keeps the pre-existing
97
+ // `skill <name>` label so it doesn't churn every doctor transcript.
98
+ const label = installedEntries.length > 1 ? `skill ${skill.name} (${entry.scope})` : `skill ${skill.name}`;
99
+ if (entry.state === 'current') {
100
+ record('pass', label, `${displayPath} — v${entry.installed}`);
101
+ } else if (entry.state === 'outdated') {
102
+ record(
103
+ 'warn',
104
+ label,
105
+ `${displayPath} — v${entry.installed}, shipped v${entry.shipped} — ` +
106
+ `run \`lorekit update --${entry.scope}\` to refresh`,
107
+ );
108
+ } else {
109
+ record(
110
+ 'warn',
111
+ label,
112
+ `${displayPath} — version unknown (legacy install predates version stamping) — ` +
113
+ `run \`lorekit update --${entry.scope}\` to refresh`,
114
+ );
115
+ }
86
116
  }
87
117
  }
88
118
 
@@ -11,7 +11,7 @@ import { resolveProjectRoot } from '../shared/config.mjs';
11
11
  import { loadControl, resolveDenies } from '../shared/control.mjs';
12
12
  import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
13
13
  import { log, err, c, select } from '../shared/util.mjs';
14
- import { parseIntFlag } from '../shared/flags.mjs';
14
+ import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
15
15
 
16
16
  /** Resolve the groom.preview/groom.run request from CLI args. */
17
17
  export function parseGroomRequest(args) {
@@ -33,6 +33,8 @@ export function parseGroomRequest(args) {
33
33
  if (maxRead.error) return { error: maxRead.error };
34
34
  const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
35
35
  if (maxOpened.error) return { error: maxOpened.error };
36
+ const dims = parseDimensionConditions(args, { clearable: false });
37
+ if (dims.error) return { error: dims.error };
36
38
 
37
39
  return {
38
40
  request: {
@@ -42,6 +44,7 @@ export function parseGroomRequest(args) {
42
44
  max_seen_count: maxSeen.value,
43
45
  max_read_count: maxRead.value,
44
46
  max_opened_count: maxOpened.value,
47
+ ...dims.conditions,
45
48
  },
46
49
  };
47
50
  }
@@ -30,6 +30,7 @@ import {
30
30
  recordShownLessons,
31
31
  } from '../core/state.mjs';
32
32
  import { recordFixture } from '../core/record.mjs';
33
+ import { resolveUpdateNudge } from '../core/update-notify.mjs';
33
34
  import { claude } from '../adapters/claude.mjs';
34
35
  import { cursor } from '../adapters/cursor.mjs';
35
36
  import { codex } from '../adapters/codex.mjs';
@@ -62,6 +63,20 @@ function hookMeterAttrs(meter) {
62
63
  return attrs;
63
64
  }
64
65
 
66
+ // Append the offline skill-update nudge to the SessionStart block, when the
67
+ // throttle allows it. Best-effort by design — a broken drift check or a
68
+ // throttle-file write failure must never cost the reader their lessons block,
69
+ // so any error here silently falls back to `text` unmodified.
70
+ function appendUpdateNudge(text, root, control) {
71
+ try {
72
+ const nudge = resolveUpdateNudge(root, { notify: control.updatesNotify });
73
+ if (!nudge) return text;
74
+ return text ? `${text}\n\n${nudge}` : nudge;
75
+ } catch {
76
+ return text;
77
+ }
78
+ }
79
+
65
80
  function readStdin() {
66
81
  return new Promise((resolve) => {
67
82
  let data = '';
@@ -164,9 +179,10 @@ async function run(args, meter) {
164
179
  // No store: emit a minimal header + instruction when present, then return.
165
180
  // A SessionStart that cannot read lore is the highest-value failure to see.
166
181
  meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
167
- if (sessionInstruction) {
168
- emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
169
- }
182
+ const base = sessionInstruction
183
+ ? formatLessons(null, { repoScope: null }, { instruction: sessionInstruction })
184
+ : null;
185
+ emit(appendUpdateNudge(base, root, control));
170
186
  return 0;
171
187
  }
172
188
  const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
@@ -174,7 +190,7 @@ async function run(args, meter) {
174
190
  branchHint: control.hooksSessionStartBranchHint !== 'off',
175
191
  maxLessons: control.hooksSessionStartMaxLessons,
176
192
  });
177
- emit(formatLessons(lessons, readScope, {
193
+ const lessonsBlock = formatLessons(lessons, readScope, {
178
194
  instruction: sessionInstruction,
179
195
  mode: control.hooksSessionStart,
180
196
  maxChars: control.hooksSessionStartMaxChars,
@@ -192,7 +208,8 @@ async function run(args, meter) {
192
208
  // `recordShownLessons` never throws, and a failure costs at most one
193
209
  // repeated lesson later in the session.
194
210
  onShown: (rendered) => recordShownLessons(parsed.sessionId, rendered.map(lessonId)),
195
- }));
211
+ });
212
+ emit(appendUpdateNudge(lessonsBlock, root, control));
196
213
  return 0;
197
214
  }
198
215
 
@@ -15,7 +15,19 @@ import { resolveProjectRoot } from '../shared/config.mjs';
15
15
  import { loadControl, resolveDenies } from '../shared/control.mjs';
16
16
  import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
17
17
  import { log, err, c, select } from '../shared/util.mjs';
18
- import { parseIntFlag } from '../shared/flags.mjs';
18
+ import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
19
+
20
+ /** `field` → display label for `formatPolicy`'s dimension summary. */
21
+ const DIMENSION_DISPLAY = [
22
+ ['tags', 'tags_mode', 'tags'],
23
+ ['source_agent', 'source_agent_mode', 'source_agent'],
24
+ ['trigger', 'trigger_mode', 'trigger'],
25
+ ['kind', 'kind_mode', 'kind'],
26
+ ['host', 'host_mode', 'host'],
27
+ ['origin_repo', 'origin_repo_mode', 'origin_repo'],
28
+ ['origin_branch', 'origin_branch_mode', 'origin_branch'],
29
+ ['origin_pr', 'origin_pr_mode', 'origin_pr'],
30
+ ];
19
31
 
20
32
  function pickRemote({ root, env, args }) {
21
33
  const { remoteDenied } = resolveDenies(root, { env });
@@ -32,6 +44,12 @@ function formatPolicy(p) {
32
44
  if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
33
45
  if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
34
46
  if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
47
+ for (const [field, modeField, label] of DIMENSION_DISPLAY) {
48
+ const values = p[field];
49
+ if (!Array.isArray(values) || values.length === 0) continue;
50
+ const mode = p[modeField];
51
+ conditions.push(`${label}${mode ? `:${mode}` : ''}=[${values.join(',')}]`);
52
+ }
35
53
  const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
36
54
  return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
37
55
  }
@@ -52,7 +70,7 @@ async function list(args, store) {
52
70
 
53
71
  async function create(args, store) {
54
72
  if (!args.scope || !args.name) {
55
- err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
73
+ err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N] [--tags a,b --tags-mode any|all|none] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]`);
56
74
  return 1;
57
75
  }
58
76
  const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
@@ -69,6 +87,8 @@ async function create(args, store) {
69
87
  err(`${c.red('Error:')} --mode must be "review" or "auto"`);
70
88
  return 1;
71
89
  }
90
+ const dims = parseDimensionConditions(args, { clearable: false });
91
+ if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
72
92
 
73
93
  const res = await store.policyCreate({
74
94
  scope: args.scope,
@@ -80,6 +100,7 @@ async function create(args, store) {
80
100
  max_seen_count: maxSeen.value,
81
101
  max_read_count: maxRead.value,
82
102
  max_opened_count: maxOpened.value,
103
+ ...dims.conditions,
83
104
  });
84
105
  if (!res.ok) {
85
106
  const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
@@ -95,7 +116,7 @@ async function create(args, store) {
95
116
  async function update(args, store) {
96
117
  const id = args._[2];
97
118
  if (!id) {
98
- err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
119
+ err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N|--clear-min-age-days] [...] [--tags a,b|--clear-tags] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b|--clear-<dim>] [--<dim>-mode ...]`);
99
120
  return 1;
100
121
  }
101
122
  if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
@@ -121,6 +142,9 @@ async function update(args, store) {
121
142
  if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); return 1; }
122
143
  patch[field] = parsed.value;
123
144
  }
145
+ const dims = parseDimensionConditions(args, { clearable: true });
146
+ if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
147
+ Object.assign(patch, dims.conditions);
124
148
  if (Object.keys(patch).length === 0) {
125
149
  err(`${c.red('Error:')} at least one field to update is required`);
126
150
  return 1;
@@ -0,0 +1,190 @@
1
+ // `lorekit update` — refresh the bundled skills (and their hook wiring) to the
2
+ // versions shipped with the running CLI.
3
+ //
4
+ // Fully offline: the shipped skill source travels in the SAME npm tarball as
5
+ // this running CLI (`SKILLS[].source` in `shared/config.mjs`), so "installed
6
+ // vs shipped" is a filesystem compare with zero network calls — see
7
+ // `shared/skill-versions.mjs`, the same module `doctor` and the SessionStart
8
+ // drift nudge (`core/update-notify.mjs`) read through, so all three surfaces
9
+ // agree on what counts as outdated.
10
+ //
11
+ // `--check` is a dry run: report drift, write nothing. Without it, `update`
12
+ // first PRUNES any file present in the install that the shipped skill no
13
+ // longer ships (`pruneRemoved` — `copyDir` only ever writes, so without this
14
+ // a rule file a newer version dropped would survive every future refresh
15
+ // forever), then re-copies (force) every skill into whichever scope(s)
16
+ // already have an install — reusing `copyDir`, the exact same skill-copy path
17
+ // `install` uses, so the two can never drift on what "installing a skill"
18
+ // means — and refreshes that scope's hook command string via `upsertClaudeHooks`, the
19
+ // same call `install --force` makes. That refresh is deliberately in scope:
20
+ // a stale `npx -y @lorekit/cli@1.2.3 hook …` pin or an old runner path is the
21
+ // same class of drift this command exists to fix, the call is idempotent
22
+ // (it only rewrites the command string, never the wired event set), and
23
+ // skipping it would leave `update` unable to repair the one other thing an
24
+ // install can go stale on. `--project` / `--global` narrow to one scope;
25
+ // with neither, every scope that currently has an existing skill install is
26
+ // refreshed — `update` never CREATES a fresh install, that stays `install`'s
27
+ // job.
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import {
31
+ SKILLS,
32
+ resolveProjectRoot,
33
+ skillInstallDir,
34
+ copyDir,
35
+ installedHookEvents,
36
+ upsertClaudeHooks,
37
+ resolveHookRunner,
38
+ } from '../shared/config.mjs';
39
+ import { checkSkillVersions, SKILL_SCOPES } from '../shared/skill-versions.mjs';
40
+ import { log, heading, status, c } from '../shared/util.mjs';
41
+
42
+ // Which scopes `update` should touch. Explicit `--project`/`--global` narrow
43
+ // to exactly one — but only when that scope actually has something installed;
44
+ // naming a scope with nothing in it must still fall through to the "nothing
45
+ // to update" report below, never silently report health on an empty scope.
46
+ // `--global` is checked first, matching `install`/`uninstall`'s own
47
+ // scope-flag precedence, so `--project --global` never picks the opposite
48
+ // scope from its sibling commands.
49
+ // With neither flag, every scope holding at least one installed skill (any
50
+ // state other than `not-installed`) is refreshed.
51
+ function targetScopes(args, results) {
52
+ const hasInstall = (scope) => results.some((r) => r.scope === scope && r.state !== 'not-installed');
53
+ if (args.global) return hasInstall('global') ? ['global'] : [];
54
+ if (args.project) return hasInstall('project') ? ['project'] : [];
55
+ return SKILL_SCOPES.filter(hasInstall);
56
+ }
57
+
58
+ // Remove any file under `dest` that no longer exists in `src` before the
59
+ // refresh copy. `copyDir` only ever WRITES — it has no delete path — so a
60
+ // rule/reference file a newer skill version dropped would otherwise survive
61
+ // every future `update` forever, still sitting on disk and still read by the
62
+ // agent alongside the content that superseded it, while `update` reports a
63
+ // clean "already up to date" or a green "refreshed". Safe to prune
64
+ // unconditionally: `update` never touches a scope the caller didn't already
65
+ // have installed, and the refresh that follows overwrites everything that
66
+ // DOES still exist in `src` anyway (`--force`), so nothing reachable from the
67
+ // shipped skill is ever at risk — only content the shipped skill no longer
68
+ // ships is removed.
69
+ // `dryRun: true` counts what WOULD be removed without touching disk — the
70
+ // preview `--check` shows for the one destructive step `update` takes, so a
71
+ // dry run never has to say "outdated" and stay silent about a file the real
72
+ // run is about to delete.
73
+ function pruneRemoved(src, dest, { dryRun = false } = {}) {
74
+ if (!fs.existsSync(dest)) return 0;
75
+ let removed = 0;
76
+ for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
77
+ const destPath = path.join(dest, entry.name);
78
+ const srcPath = path.join(src, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
81
+ removed += pruneRemoved(srcPath, destPath, { dryRun });
82
+ } else {
83
+ if (!dryRun) fs.rmSync(destPath, { recursive: true, force: true });
84
+ removed++;
85
+ }
86
+ } else if (!fs.existsSync(srcPath)) {
87
+ if (!dryRun) fs.rmSync(destPath, { force: true });
88
+ removed++;
89
+ }
90
+ }
91
+ return removed;
92
+ }
93
+
94
+ const versionLabel = (v) => (v ? `v${v}` : 'unknown');
95
+
96
+ export async function update(args) {
97
+ const root = resolveProjectRoot(args.dir);
98
+ const dryRun = Boolean(args.check);
99
+
100
+ heading(dryRun ? 'LoreKit update (--check)' : 'LoreKit update');
101
+ log(` project: ${c.dim(root)}`);
102
+
103
+ const results = checkSkillVersions(root);
104
+ const scopes = targetScopes(args, results);
105
+
106
+ if (scopes.length === 0) {
107
+ log('');
108
+ log(` ${c.dim('no existing skill install found — nothing to update.')}`);
109
+ log(` Run ${c.cyan('lorekit install')} for a fresh install.`);
110
+ return { exitCode: 0, 'lorekit.cli.update.scopes': 0, 'lorekit.cli.update.outdated': 0, 'lorekit.cli.update.check': dryRun };
111
+ }
112
+
113
+ heading('Skills');
114
+ let outdatedCount = 0;
115
+ let filesWritten = 0;
116
+ let filesRemoved = 0;
117
+ for (const scope of scopes) {
118
+ for (const skill of SKILLS) {
119
+ const entry = results.find((r) => r.scope === scope && r.name === skill.name);
120
+ if (!entry || entry.state === 'not-installed') continue;
121
+ const label = scopes.length > 1 ? `skill ${skill.name} (${scope})` : `skill ${skill.name}`;
122
+
123
+ if (entry.state === 'current') {
124
+ status('pass', label, `${versionLabel(entry.installed)} — already up to date`);
125
+ continue;
126
+ }
127
+
128
+ outdatedCount++;
129
+ const before = versionLabel(entry.installed);
130
+ const after = versionLabel(entry.shipped);
131
+ const dest = skillInstallDir(root, scope, skill.name);
132
+ if (dryRun) {
133
+ const wouldRemove = pruneRemoved(skill.source, dest, { dryRun: true });
134
+ const removedNote = wouldRemove > 0 ? `, ${wouldRemove} to remove` : '';
135
+ status('warn', label, `${before} → ${after} available${removedNote} — run \`lorekit update\` to refresh`);
136
+ continue;
137
+ }
138
+
139
+ const removed = pruneRemoved(skill.source, dest);
140
+ const written = copyDir(skill.source, dest, { force: true });
141
+ filesWritten += written;
142
+ filesRemoved += removed;
143
+ const removedNote = removed > 0 ? `, ${removed} removed` : '';
144
+ status('pass', label, `${before} → ${after} (${written} file(s) written${removedNote})`);
145
+ }
146
+ }
147
+
148
+ // Hook command strings — refreshed for real, never on a dry run. Only a
149
+ // scope that already has hooks wired has anything to refresh; a scope with
150
+ // none stays untouched (matching `install`'s own "nothing to wire" no-op).
151
+ // `upsertClaudeHooks` rewrites `.claude/settings.json` unconditionally
152
+ // (formatting included) even when every entry was already `unchanged` —
153
+ // tallying its returned counts is what lets the no-skills-outdated report
154
+ // below say so instead of silently rewriting the file underneath the user.
155
+ let hooksChanged = 0;
156
+ if (!dryRun) {
157
+ for (const scope of scopes) {
158
+ const wired = installedHookEvents(root, scope);
159
+ if (wired.length === 0) continue;
160
+ const stats = upsertClaudeHooks(root, scope, resolveHookRunner(), wired);
161
+ hooksChanged += stats.added + stats.updated + stats.removed + stats.deduped;
162
+ }
163
+ }
164
+
165
+ log('');
166
+ if (outdatedCount === 0) {
167
+ const hooksNote = hooksChanged > 0 ? ` (hook wiring refreshed: ${hooksChanged} change${hooksChanged === 1 ? '' : 's'})` : '';
168
+ log(` ${c.green('✓')} every installed skill is already at the shipped version${hooksNote}.`);
169
+ } else if (dryRun) {
170
+ const plural = outdatedCount === 1 ? '' : 's';
171
+ log(` ${c.yellow('!')} ${outdatedCount} skill install${plural} outdated — run \`lorekit update\` to apply.`);
172
+ } else {
173
+ const plural = outdatedCount === 1 ? '' : 's';
174
+ const removedNote = filesRemoved > 0 ? `, ${filesRemoved} removed` : '';
175
+ log(` ${c.green('✓')} refreshed ${outdatedCount} skill install${plural} (${filesWritten} file(s) written${removedNote}).`);
176
+ }
177
+ log('');
178
+
179
+ return {
180
+ // Non-zero only for a `--check` run that found drift — a real run always
181
+ // finishes at 0 (it just fixed whatever it found), matching `doctor`'s own
182
+ // deliberate warn-never-fail posture. This is what lets `--check` gate a
183
+ // CI step or pre-commit hook on "everything is current" instead of only
184
+ // ever reporting drift for a human to notice.
185
+ exitCode: dryRun && outdatedCount > 0 ? 1 : 0,
186
+ 'lorekit.cli.update.scopes': scopes.length,
187
+ 'lorekit.cli.update.outdated': outdatedCount,
188
+ 'lorekit.cli.update.check': dryRun,
189
+ };
190
+ }
package/src/commands.mjs CHANGED
@@ -36,6 +36,7 @@
36
36
  import { install } from './commands/install.mjs';
37
37
  import { uninstall } from './commands/uninstall.mjs';
38
38
  import { doctor } from './commands/doctor.mjs';
39
+ import { update } from './commands/update.mjs';
39
40
  import { list } from './commands/list.mjs';
40
41
  import { search } from './commands/search.mjs';
41
42
  import { show } from './commands/show.mjs';
@@ -74,6 +75,7 @@ export const COMMANDS = [
74
75
  { name: 'install', run: install, traced: true, strictFlags: true, native: 'scaffolds skills, hooks and MCP config on disk' },
75
76
  { name: 'uninstall', run: uninstall, traced: true, strictFlags: true, native: 'removes what install wrote' },
76
77
  { name: 'doctor', run: doctor, traced: true, strictFlags: true, native: 'connectivity / token / scope health check' },
78
+ { name: 'update', run: update, traced: true, strictFlags: true, native: 'offline refresh of the bundled skills + hook wiring' },
77
79
  { name: 'list', run: list, traced: true, strictFlags: true, tool: 'memory.list', aliases: ['ls'] },
78
80
  { name: 'search', run: search, traced: true, strictFlags: true, tool: 'memory.search', aliases: ['grep'] },
79
81
  { name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' },
@@ -0,0 +1,154 @@
1
+ // SessionStart skill-update drift nudge: a terse, throttled line telling the
2
+ // agent "your skills are stale, run `lorekit update`" — computed entirely
3
+ // offline (see ../shared/skill-versions.mjs) and gated by `updates.notify`
4
+ // (../shared/control.mjs).
5
+ //
6
+ // Throttle state lives at `$LOREKIT_HOME/update-state.json`, following the
7
+ // exact precedent `telemetry/telemetry-identity.mjs` sets for a small,
8
+ // user-owned JSON file under the home tier: TOTAL reads (a missing/corrupt
9
+ // file degrades to `{}`, never throws), atomic writes via
10
+ // `shared/config.mjs`'s `writeFileAtomic`, and the file is written ONLY when
11
+ // there is something to record — never merely because this ran.
12
+ //
13
+ // The nudge fires at most once per NEW shipped-version signature, plus a
14
+ // 7-day cooldown on repeating that same signature. `updates.notify: off`
15
+ // short-circuits before any filesystem check runs, so an opted-out user's
16
+ // disk is never touched by this module at all.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+ import { homeRoot } from '../shared/control.mjs';
21
+ import { writeFileAtomic } from '../shared/config.mjs';
22
+ import { checkSkillVersions, skillsNeedingUpdate } from '../shared/skill-versions.mjs';
23
+
24
+ const STATE_FILE = 'update-state.json';
25
+ const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
26
+
27
+ /** `$LOREKIT_HOME/update-state.json`, default `~/.lorekit/update-state.json`. */
28
+ export function updateStatePath(env = process.env) {
29
+ return path.join(homeRoot(env), STATE_FILE);
30
+ }
31
+
32
+ /**
33
+ * Read the stored throttle state, or `{}`. TOTAL — a missing file, an
34
+ * unreadable one, a corrupt body, or a body that parses but isn't the right
35
+ * shape all yield `{}`, exactly like `telemetry-identity.mjs`'s `readIdentity`.
36
+ */
37
+ export function readUpdateState(file = updateStatePath()) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
40
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
41
+ const out = {};
42
+ if (typeof parsed.lastNotifiedVersion === 'string' && parsed.lastNotifiedVersion) {
43
+ out.lastNotifiedVersion = parsed.lastNotifiedVersion;
44
+ }
45
+ if (typeof parsed.lastNotifiedAt === 'number' && Number.isFinite(parsed.lastNotifiedAt)) {
46
+ out.lastNotifiedAt = parsed.lastNotifiedAt;
47
+ }
48
+ return out;
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ // Persist the throttle state, creating the home directory if needed. Returns
55
+ // true on success, false on any failure (unwritable home, full disk) — a
56
+ // write failure must not surface as an error, since a hook this fires from
57
+ // must never break the host on a filesystem hiccup.
58
+ function writeUpdateState(state, file = updateStatePath()) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A stable signature naming exactly which skills need an update and at which
70
+ * shipped version, e.g. `"lorekit-memory@1.2.0,lorekit-setup@1.1.0"`. Sorted
71
+ * by skill name so the signature is deterministic regardless of scan order.
72
+ *
73
+ * This — not a bare boolean — is what the throttle compares against: a NEW
74
+ * release (a shipped version bump) or a previously-current skill drifting
75
+ * produces a DIFFERENT signature, which re-qualifies for a nudge even inside
76
+ * the 7-day cooldown on the old one. `""` means nothing needs an update.
77
+ */
78
+ export function driftSignature(results) {
79
+ const bySkill = new Map();
80
+ for (const r of skillsNeedingUpdate(results)) {
81
+ if (!r.shipped || bySkill.has(r.name)) continue;
82
+ bySkill.set(r.name, r.shipped);
83
+ }
84
+ return [...bySkill.entries()]
85
+ .sort(([a], [b]) => a.localeCompare(b))
86
+ .map(([name, shipped]) => `${name}@${shipped}`)
87
+ .join(',');
88
+ }
89
+
90
+ /**
91
+ * Render the one-line nudge, or `null` when nothing needs an update. De-duped
92
+ * by skill name (the same `name -> shipped` Map `driftSignature` builds) —
93
+ * `skillsNeedingUpdate` returns one row per (skill, scope), and without the
94
+ * dedupe a skill outdated in BOTH the project and global install would count
95
+ * itself twice in the "(+N more)" tail. An `unknown` row (no readable
96
+ * installed version — the drift signature counts it too, see
97
+ * `driftSignature`) still gets named, just without a "vX →" on its left side,
98
+ * so the nudge is never silently dropped for the one state that most needs a
99
+ * "go look" — a legacy install with no parseable version at all.
100
+ */
101
+ export function formatUpdateNudge(results) {
102
+ const bySkill = new Map();
103
+ for (const r of skillsNeedingUpdate(results)) {
104
+ if (!r.shipped || bySkill.has(r.name)) continue;
105
+ bySkill.set(r.name, r);
106
+ }
107
+ const needing = [...bySkill.values()];
108
+ if (needing.length === 0) return null;
109
+ const [first, ...rest] = needing;
110
+ const extra = rest.length > 0 ? ` (+${rest.length} more)` : '';
111
+ const headline = first.installed
112
+ ? `${first.name} v${first.installed} → v${first.shipped}`
113
+ : `${first.name} → v${first.shipped}`;
114
+ return (
115
+ `LoreKit skills are outdated (${headline}${extra}). ` +
116
+ 'Run `lorekit update` to refresh.'
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The full resolve: should a SessionStart nudge fire right now, and if so,
122
+ * record the throttle state and return the text? `null` means stay silent —
123
+ * `notify: off`, nothing outdated, or the same signature is still inside its
124
+ * 7-day cooldown.
125
+ *
126
+ * TOTAL and side-effect-light: nothing is read or written when `notify` is
127
+ * `off`, and the state file is written ONLY on the turn that actually emits a
128
+ * nudge — never merely because this ran. Any unexpected error (a throwing
129
+ * filesystem call the individual helpers didn't already swallow) is caught
130
+ * here too, so a caller never needs its own try/catch to stay safe.
131
+ */
132
+ export function resolveUpdateNudge(root, { notify = 'auto', now = Date.now(), env = process.env } = {}) {
133
+ if (notify === 'off') return null;
134
+ try {
135
+ const results = checkSkillVersions(root);
136
+ const signature = driftSignature(results);
137
+ if (!signature) return null;
138
+
139
+ const file = updateStatePath(env);
140
+ const state = readUpdateState(file);
141
+ const sameSignature = state.lastNotifiedVersion === signature;
142
+ const withinCooldown =
143
+ typeof state.lastNotifiedAt === 'number' && now - state.lastNotifiedAt < COOLDOWN_MS;
144
+ if (sameSignature && withinCooldown) return null;
145
+
146
+ const text = formatUpdateNudge(results);
147
+ if (!text) return null;
148
+
149
+ writeUpdateState({ lastNotifiedVersion: signature, lastNotifiedAt: now }, file);
150
+ return text;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
@@ -43,6 +43,7 @@ const FLAG = {
43
43
  force: { desc: 'Overwrite / hard-delete' },
44
44
  deep: { desc: 'Do a write→read→delete round-trip' },
45
45
  telemetry: { desc: 'Verify the OTLP export credential works' },
46
+ check: { desc: 'Report drift without writing anything' },
46
47
  json: { desc: 'Machine-readable output' },
47
48
  scope: { desc: 'Restrict to / name a scope', arg: 'scope', complete: 'scope' },
48
49
  key: { desc: 'Name the key explicitly', arg: 'key' },
@@ -107,6 +108,8 @@ const COMMANDS = [
107
108
  flags: ['dir', 'project', 'global', 'yes'] },
108
109
  { name: 'doctor', summary: 'Verify the install, connectivity, token, scope',
109
110
  flags: ['dir', 'mode', 'endpoint', 'token', 'store', 'deep', 'telemetry'] },
111
+ { name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version',
112
+ flags: ['dir', 'project', 'global', 'check'] },
110
113
  { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'],
111
114
  flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
112
115
  { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'],
@@ -61,6 +61,21 @@ export function normalizeStopMode(v) {
61
61
  return null;
62
62
  }
63
63
 
64
+ // `updates.notify` — whether the SessionStart hook may append a terse
65
+ // skill-update nudge when an installed skill has drifted behind the version
66
+ // this CLI ships. `auto` (default) | `off`. Same forgiving-vocabulary /
67
+ // repo-wins model as `hooks.stop`: a plain boolean, `false`/`none`/`disabled`
68
+ // all mean `off`, so a hand-edited JSON file reads naturally either way.
69
+ export const UPDATE_NOTIFY_MODES = ['auto', 'off'];
70
+ export function normalizeUpdateNotifyMode(v) {
71
+ if (typeof v === 'boolean') return v ? 'auto' : 'off';
72
+ if (typeof v !== 'string') return null;
73
+ const s = v.trim().toLowerCase();
74
+ if (['off', 'none', 'false', 'disabled', 'never', 'no'].includes(s)) return 'off';
75
+ if (['auto', 'on', 'true', 'enabled', 'always', 'yes'].includes(s)) return 'auto';
76
+ return null;
77
+ }
78
+
64
79
  // `hooks.userPrompt` — the per-turn relevance pull, on or off.
65
80
  //
66
81
  // A BOOLEAN, not a mode, and that is a deliberate limit on the surface. The
@@ -454,6 +469,12 @@ export function resolveControl({
454
469
  normalizeUserPromptMode(userConfig['hooks.sessionStart.branchHint']) ||
455
470
  'on';
456
471
 
472
+ // `updates.notify` — repo layer wins over user layer, default `auto`.
473
+ const updatesNotify =
474
+ normalizeUpdateNotifyMode(repoConfig['updates.notify']) ||
475
+ normalizeUpdateNotifyMode(userConfig['updates.notify']) ||
476
+ 'auto';
477
+
457
478
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
458
479
  const hooksAdapter =
459
480
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -499,6 +520,7 @@ export function resolveControl({
499
520
  hooksSessionStartBranchHint,
500
521
  hooksAdapter,
501
522
  hooksInstructions,
523
+ updatesNotify,
502
524
  };
503
525
  }
504
526
 
@@ -14,3 +14,76 @@ export function parseIntFlag(raw, name) {
14
14
  }
15
15
  return { value: Number(String(raw).trim()) };
16
16
  }
17
+
18
+ /**
19
+ * The eight dimension filters a retention policy / inline groom call can
20
+ * carry (migration 00093) — the CLI's flag-name ↔ field-name ↔ valid-mode
21
+ * table. Mirrors `groomDimensionFilterProperties` in
22
+ * `packages/schemas/src/shared/tool-catalog.ts` and `GROOM_DIMENSION_FIELDS`
23
+ * in `supabase/functions/mcp/tools.ts`; kept as ONE list here (not three) so
24
+ * `policy create`/`policy update`/`groom` cannot describe the dimensions
25
+ * differently.
26
+ */
27
+ const DIMENSIONS = [
28
+ { flag: 'tags', field: 'tags', modes: ['any', 'all', 'none'] },
29
+ { flag: 'source-agent', field: 'source_agent', modes: ['in', 'nin'] },
30
+ { flag: 'trigger', field: 'trigger', modes: ['in', 'nin'] },
31
+ { flag: 'kind', field: 'kind', modes: ['in', 'nin'] },
32
+ { flag: 'host', field: 'host', modes: ['in', 'nin'] },
33
+ { flag: 'origin-repo', field: 'origin_repo', modes: ['in', 'nin'] },
34
+ { flag: 'origin-branch', field: 'origin_branch', modes: ['in', 'nin'] },
35
+ { flag: 'origin-pr', field: 'origin_pr', modes: ['in', 'nin'] },
36
+ ];
37
+
38
+ /**
39
+ * Parse the eight retention dimension filters (`--tags`/`--kind`/`--host`/
40
+ * `--trigger`/`--source-agent`/`--origin-repo`/`--origin-branch`/`--origin-pr`,
41
+ * each with a `--<dim>-mode`) out of a parsed-args object.
42
+ *
43
+ * Comma-separated values — matching the existing `write --tags a,b,c`
44
+ * convention. The minimal flag parser (`bin/lorekit.mjs`'s `parseArgs`) is
45
+ * last-wins on a repeated flag, so comma-splitting is the established
46
+ * multi-value shape in this CLI, not a new one invented here.
47
+ *
48
+ * `--<dim>-mode` is validated against that DIMENSION's own enum (`tags-mode`
49
+ * accepts any/all/none; every other dimension accepts in/nin) — an invalid
50
+ * value is rejected rather than silently forwarded to the RPC as a raw
51
+ * Postgres enum error.
52
+ *
53
+ * With `clearable: true` (policy update), `--clear-<dim>` sends an explicit
54
+ * `null` for the VALUE field only, and skips both the value and mode flags for
55
+ * that dimension entirely — matching how `policy update --clear-min-age-days`
56
+ * already ignores `--min-age-days` when both are passed. The MODE field is
57
+ * left untouched by a clear: it is meaningless once the value is null, and
58
+ * silently dropping it if the caller separately set it would be a second
59
+ * surprise on top of the clear.
60
+ *
61
+ * Returns `{ conditions }` containing only the fields the caller actually
62
+ * named — never a full 16-key object of undefineds, so a caller can spread it
63
+ * straight into a request body without a second `stripUndefined` pass losing
64
+ * an intentional `null` — or `{ error }` naming the offending flag.
65
+ */
66
+ export function parseDimensionConditions(args, { clearable = false } = {}) {
67
+ const conditions = {};
68
+ for (const { flag, field, modes } of DIMENSIONS) {
69
+ if (clearable && args[`clear-${flag}`]) {
70
+ conditions[field] = null;
71
+ continue;
72
+ }
73
+ const raw = args[flag];
74
+ if (raw !== undefined) {
75
+ conditions[field] = String(raw)
76
+ .split(',')
77
+ .map((s) => s.trim())
78
+ .filter((s) => s.length > 0);
79
+ }
80
+ const modeRaw = args[`${flag}-mode`];
81
+ if (modeRaw !== undefined) {
82
+ if (!modes.includes(modeRaw)) {
83
+ return { error: `--${flag}-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` };
84
+ }
85
+ conditions[`${field}_mode`] = modeRaw;
86
+ }
87
+ }
88
+ return { conditions };
89
+ }
@@ -0,0 +1,148 @@
1
+ // Offline "installed vs shipped" skill version check.
2
+ //
3
+ // `lorekit install` copies each skill's SKILL.md verbatim and stamps no version
4
+ // anywhere else, so a skill installed at an old version has reported a healthy
5
+ // `doctor` PASS forever with no signal to update. The version data already
6
+ // exists: every skill's `SKILL.md` frontmatter carries `metadata.version`
7
+ // (e.g. `'1.0.0'`), and the shipped skill source travels in the SAME npm
8
+ // tarball as the running CLI (`SKILLS[].source` in `./config.mjs`) — so
9
+ // comparing "what's on disk" against "what this CLI ships" needs zero network
10
+ // calls and no server round-trip. `doctor` (per-scope reporting) and
11
+ // `lorekit update` (the refresh command) and the SessionStart drift nudge
12
+ // (`../core/update-notify.mjs`) all read through this one module so the three
13
+ // surfaces can't disagree about what counts as outdated.
14
+ //
15
+ // Dependency-free `.mjs`, like the other `shared/*.mjs` pure modules — no npm
16
+ // imports beyond node builtins.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { SKILLS, skillInstallDir } from './config.mjs';
20
+
21
+ /**
22
+ * Pull the YAML frontmatter block out of a SKILL.md body — the text between
23
+ * the first `---` line and the next one. `null` when there is no such block
24
+ * (not a SKILL.md-shaped file, or a corrupt/truncated one).
25
+ */
26
+ export function extractFrontmatter(markdown) {
27
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(typeof markdown === 'string' ? markdown : '');
28
+ return m ? m[1] : null;
29
+ }
30
+
31
+ /**
32
+ * Parse `metadata.version` out of a SKILL.md's frontmatter.
33
+ *
34
+ * Deliberately minimal: every SKILL.md this CLI ships has exactly one
35
+ * `version:` line (nested under `metadata:`), so a plain line match — robust
36
+ * to single/double quotes and a trailing comment — is enough without pulling
37
+ * in a YAML parser this zero-dep package does not otherwise need. Returns
38
+ * `null` when the frontmatter is absent or carries no `version:` line at all
39
+ * (a legacy skill file predating the version stamp).
40
+ */
41
+ export function parseSkillVersion(markdown) {
42
+ const frontmatter = extractFrontmatter(markdown);
43
+ if (!frontmatter) return null;
44
+ const m = /^[ \t]*version:[ \t]*(['"]?)([^'"\n#]+?)\1[ \t]*(?:#.*)?$/m.exec(frontmatter);
45
+ if (!m) return null;
46
+ const version = m[2].trim();
47
+ return version || null;
48
+ }
49
+
50
+ // Read + parse a SKILL.md's version, or `null` for anything that isn't a
51
+ // readable, parseable file — an absent path, a permissions error, and a file
52
+ // with no `version:` line all collapse to the same "unknown" signal.
53
+ function readVersion(skillMdPath) {
54
+ try {
55
+ return parseSkillVersion(fs.readFileSync(skillMdPath, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Dependency-free semver-ish compare of two dotted-numeric version strings.
63
+ *
64
+ * Returns `-1` / `0` / `1` the usual way, or `null` when either input is not a
65
+ * usable version string (missing, empty, or containing a non-numeric segment —
66
+ * pre-release/build metadata like `1.0.0-beta` is intentionally out of scope,
67
+ * since every shipped SKILL.md version is a plain `x.y.z`). `null` reads as
68
+ * "cannot compare", which callers treat as `unknown` rather than guessing a
69
+ * direction.
70
+ */
71
+ export function compareVersions(a, b) {
72
+ if (typeof a !== 'string' || typeof b !== 'string' || !a.trim() || !b.trim()) return null;
73
+ const toParts = (v) => v.trim().split('.').map((seg) => Number(seg));
74
+ const pa = toParts(a);
75
+ const pb = toParts(b);
76
+ if (pa.some((n) => !Number.isFinite(n)) || pb.some((n) => !Number.isFinite(n))) return null;
77
+ const len = Math.max(pa.length, pb.length);
78
+ for (let i = 0; i < len; i++) {
79
+ const x = pa[i] ?? 0;
80
+ const y = pb[i] ?? 0;
81
+ if (x < y) return -1;
82
+ if (x > y) return 1;
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ /** The scopes a skill can be installed in — matches `skillInstallDir`'s. */
88
+ export const SKILL_SCOPES = ['project', 'global'];
89
+
90
+ /**
91
+ * Classify one (skill, scope) pair against the version this CLI ships.
92
+ *
93
+ * `state`:
94
+ * - `not-installed` — no SKILL.md at that scope.
95
+ * - `current` — installed version >= shipped version.
96
+ * - `outdated` — installed version < shipped version.
97
+ * - `unknown` — a SKILL.md exists but a version could not be
98
+ * resolved on one side (an unparseable/legacy
99
+ * installed file, or — in practice never — an
100
+ * unparseable shipped one), so no direction can be
101
+ * asserted.
102
+ */
103
+ function classify(installedPath, shipped) {
104
+ const installedExists = fs.existsSync(installedPath);
105
+ if (!installedExists) {
106
+ return { installed: null, state: 'not-installed' };
107
+ }
108
+ const installed = readVersion(installedPath);
109
+ if (installed == null || shipped == null) {
110
+ return { installed, state: 'unknown' };
111
+ }
112
+ const cmp = compareVersions(installed, shipped);
113
+ if (cmp === null) return { installed, state: 'unknown' };
114
+ return { installed, state: cmp < 0 ? 'outdated' : 'current' };
115
+ }
116
+
117
+ /**
118
+ * Check every skill in `skills` (default: the CLI's own `SKILLS` list) at
119
+ * every scope, comparing the INSTALLED `SKILL.md`'s `metadata.version`
120
+ * against the SHIPPED source's. Pure filesystem reads — no network, no store.
121
+ *
122
+ * Returns one row per (skill, scope):
123
+ * `{ name, scope, installedPath, installed, shipped, state }`
124
+ */
125
+ export function checkSkillVersions(root, { skills = SKILLS } = {}) {
126
+ const results = [];
127
+ for (const skill of skills) {
128
+ const shipped = readVersion(path.join(skill.source, 'SKILL.md'));
129
+ for (const scope of SKILL_SCOPES) {
130
+ const installedPath = path.join(skillInstallDir(root, scope, skill.name), 'SKILL.md');
131
+ const { installed, state } = classify(installedPath, shipped);
132
+ results.push({ name: skill.name, scope, installedPath, installed, shipped, state });
133
+ }
134
+ }
135
+ return results;
136
+ }
137
+
138
+ /**
139
+ * The subset of `checkSkillVersions` results that need an update — `outdated`
140
+ * or `unknown` (a legacy install with no readable version is exactly the case
141
+ * `lorekit update` exists to repair, since re-copying stamps a fresh one).
142
+ * `not-installed` and `current` are excluded — neither one is drift to act on.
143
+ */
144
+ export function skillsNeedingUpdate(results) {
145
+ return (Array.isArray(results) ? results : []).filter(
146
+ (r) => r && (r.state === 'outdated' || r.state === 'unknown'),
147
+ );
148
+ }
@@ -42,6 +42,20 @@ function stripUndefined(obj) {
42
42
  return out;
43
43
  }
44
44
 
45
+ // Drop only `undefined` — keeps an explicit `null`. Bare `stripUndefined`
46
+ // above is right for a CREATE body, where an omitted dimension and an
47
+ // explicit `null` mean the same "not filtered". It is wrong for a PATCH,
48
+ // where `null` is the caller's explicit instruction to CLEAR a
49
+ // previously-set condition (`policy update --clear-kind`) — dropping it
50
+ // there means the field silently stays whatever it already was, which is
51
+ // the opposite of what was asked. Named for what it keeps, not what it
52
+ // drops, since "strip" alone reads as "strip everything falsy-ish".
53
+ function stripUndefinedKeepNull(obj) {
54
+ const out = {};
55
+ for (const [k, v] of Object.entries(obj || {})) if (v !== undefined) out[k] = v;
56
+ return out;
57
+ }
58
+
45
59
  /**
46
60
  * A read that could not be answered — a transport failure or a non-2xx status,
47
61
  * as opposed to "the lesson is not there".
@@ -768,9 +782,20 @@ class RemoteStore {
768
782
  return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
769
783
  }
770
784
 
771
- // POST /policies → the created policy object.
772
- async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
773
- const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
785
+ // POST /policies → the created policy object. `...dims` carries whichever
786
+ // of the eight dimension filters (+ their `_mode` companions) the caller
787
+ // named — see `parseDimensionConditions` in `shared/flags.mjs`, the shared
788
+ // parser policy create/update and groom all go through.
789
+ async policyCreate({
790
+ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
791
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
792
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
793
+ } = {}) {
794
+ const body = stripUndefined({
795
+ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
796
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
797
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
798
+ });
774
799
  const res = await this._rest('/memories/policies', { method: 'POST', body });
775
800
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
776
801
  return { ok: true, policy: res.data };
@@ -778,10 +803,11 @@ class RemoteStore {
778
803
 
779
804
  // PATCH /policies/:id → the updated policy object. An omitted field is left
780
805
  // unchanged; pass an explicit `null` in the patch to clear a condition —
781
- // this method does not strip nulls, only `undefined` (stripUndefined keeps
782
- // that distinction, which is the whole point of the RPC's JSONB-patch design).
806
+ // this method does not strip nulls, only `undefined` (stripUndefinedKeepNull
807
+ // keeps that distinction, which is the whole point of the RPC's JSONB-patch
808
+ // design and of `policy update --clear-*`).
783
809
  async policyUpdate(id, patch = {}) {
784
- const body = stripUndefined(patch);
810
+ const body = stripUndefinedKeepNull(patch);
785
811
  const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'PATCH', body });
786
812
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
787
813
  return { ok: true, policy: res.data };
@@ -796,9 +822,18 @@ class RemoteStore {
796
822
 
797
823
  // POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
798
824
  // candidates a groom() run would archive. Pass either `policy_id` or
799
- // `scope` (+ optional conditions), never both.
800
- async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
801
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
825
+ // `scope` (+ optional conditions, including the eight dimension filters —
826
+ // see policyCreate's comment), never both.
827
+ async groomPreview({
828
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
829
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
830
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
831
+ } = {}) {
832
+ const body = stripUndefined({
833
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
834
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
835
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
836
+ });
802
837
  const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
803
838
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
804
839
  return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
@@ -806,8 +841,16 @@ class RemoteStore {
806
841
 
807
842
  // POST /groom/run → archives every previewed candidate, in one transaction.
808
843
  // Soft-archive only (recoverable via restore); never hard-deletes.
809
- async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
810
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
844
+ async groomRun({
845
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
846
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
847
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
848
+ } = {}) {
849
+ const body = stripUndefined({
850
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
851
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
852
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
853
+ });
811
854
  const res = await this._rest('/memories/groom/run', { method: 'POST', body });
812
855
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
813
856
  return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };